mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 19:22:51 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41099f0101 | ||
|
|
d8675174eb | ||
|
|
4b5c52ebe6 | ||
|
|
7c9a7c191e | ||
|
|
2d4c1aba0f | ||
|
|
4d1665699c |
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
|
||||
describe("InMemoryAgentTimelineStore", () => {
|
||||
@@ -179,10 +177,7 @@ describe("projected sequence ownership", () => {
|
||||
store.append("a", { type: "assistant_message", text: "Answer" });
|
||||
store.append("a", { ...tool, status: "completed" });
|
||||
store.initialize("b", { rows: store.getRows("a") });
|
||||
expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" });
|
||||
expect(store.getLastAssistantMessage("b")).toBe("Answer");
|
||||
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
|
||||
expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" });
|
||||
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
|
||||
});
|
||||
it("returns the last projected assistant message without joining different messages", () => {
|
||||
@@ -216,151 +211,3 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
|
||||
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
|
||||
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
||||
});
|
||||
|
||||
it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
// Generation and its GC are outside the measured request window.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const started = performance.now();
|
||||
const probe = new Promise<number>((resolve) =>
|
||||
setImmediate(() => resolve(performance.now() - started)),
|
||||
);
|
||||
let page = store.fetch("large", { limit: 200 });
|
||||
for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 });
|
||||
const duration = performance.now() - started;
|
||||
const loopGap = await probe;
|
||||
console.info({ duration, loopGap });
|
||||
expect(page.rows).toHaveLength(200);
|
||||
expect(page.startSeq).toBe(99_801);
|
||||
expect(page.endSeq).toBe(100_000);
|
||||
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
|
||||
// is measured separately. Eight requests amplify the old full-history work.
|
||||
expect(duration).toBeLessThan(50);
|
||||
expect(loopGap).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
|
||||
let seed = 0x76aabbcc;
|
||||
const random = (bound: number) => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return Math.floor((seed / 0x1_0000_0000) * bound);
|
||||
};
|
||||
for (let scenario = 0; scenario < 100; scenario++) {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a", { epoch: "e" });
|
||||
for (let seq = 1; seq <= 100; seq++) {
|
||||
const identity = String(random(8));
|
||||
const text = String(seq);
|
||||
const items: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text },
|
||||
{ type: "assistant_message", text },
|
||||
{ type: "assistant_message", text, messageId: identity },
|
||||
{ type: "reasoning", text },
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: identity,
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: text },
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: identity,
|
||||
pluginId: "test",
|
||||
kind: "state",
|
||||
version: 1,
|
||||
data: { text },
|
||||
},
|
||||
];
|
||||
store.append("a", items[random(items.length)], { turnId: String(random(3)) });
|
||||
}
|
||||
// Include rehydration of already-projected rows, with non-monotonic seqEnd.
|
||||
store.initialize("b", { rows: store.getRows("a"), epoch: "e" });
|
||||
for (const agentId of ["a", "b"]) {
|
||||
assertSelectionMatchesReference(store, agentId);
|
||||
store.append(agentId, { type: "assistant_message", text: "later" });
|
||||
assertSelectionMatchesReference(store, agentId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void {
|
||||
for (const direction of ["tail", "before", "after"] as const) {
|
||||
for (const limit of [0, 1, 7, 200]) {
|
||||
for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) {
|
||||
const actual = store.fetch(agentId, {
|
||||
direction,
|
||||
limit,
|
||||
cursor: { epoch: "e", seq: cursorSeq },
|
||||
});
|
||||
const expected = selectReferenceProjectedTimelinePage({
|
||||
rows: store.getRows(agentId),
|
||||
bounds: actual.window,
|
||||
direction,
|
||||
limit,
|
||||
cursorSeq,
|
||||
});
|
||||
expect({
|
||||
...expected,
|
||||
entries: undefined,
|
||||
rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
|
||||
}).toEqual({
|
||||
entries: undefined,
|
||||
rows: actual.rows,
|
||||
startSeq: actual.startSeq,
|
||||
endSeq: actual.endSeq,
|
||||
hasOlder: actual.hasOlder,
|
||||
hasNewer: actual.hasNewer,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a", { epoch: "e" });
|
||||
const tool: AgentTimelineItem = {
|
||||
type: "tool_call",
|
||||
callId: "t",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: "work" },
|
||||
};
|
||||
store.append("a", tool);
|
||||
for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) });
|
||||
const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 });
|
||||
expect(first.endSeq).toBe(2);
|
||||
store.append("a", { ...tool, status: "completed" });
|
||||
store.append("a", { type: "assistant_message", text: "Done" });
|
||||
expect(first.rows[0].item).toMatchObject({ status: "running" });
|
||||
const second = store.fetch("a", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: first.endSeq! },
|
||||
limit: 2,
|
||||
});
|
||||
expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]);
|
||||
expect(second.endSeq).toBe(4);
|
||||
const third = store.fetch("a", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: second.endSeq! },
|
||||
limit: 2,
|
||||
});
|
||||
expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]);
|
||||
expect(third.rows[0].item).toMatchObject({ status: "completed" });
|
||||
expect(third.endSeq).toBe(6);
|
||||
const last = store.fetch("a", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: third.endSeq! },
|
||||
limit: 2,
|
||||
});
|
||||
expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]);
|
||||
expect(last.endSeq).toBe(7);
|
||||
expect(last.hasNewer).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
|
||||
import {
|
||||
TimelineProjection,
|
||||
selectProjectedTimelinePage,
|
||||
type ProjectedTimelineRow,
|
||||
} from "./timeline-projection.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
@@ -39,7 +43,8 @@ export class InMemoryAgentTimelineStore {
|
||||
? options.rows.map(cloneRow)
|
||||
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
|
||||
const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1);
|
||||
const projection = new TimelineProjection(rows);
|
||||
const projection = new TimelineProjection();
|
||||
for (const row of rows) projection.append(row);
|
||||
this.states.set(agentId, {
|
||||
epoch: options?.epoch ?? randomUUID(),
|
||||
projection,
|
||||
@@ -92,17 +97,18 @@ export class InMemoryAgentTimelineStore {
|
||||
const state = this.requireState(agentId);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const cursor = options?.cursor;
|
||||
const hasRows = state.minSeq !== 0;
|
||||
const rows = state.projection.getRows();
|
||||
const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq };
|
||||
const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch;
|
||||
const gap =
|
||||
!staleCursor &&
|
||||
direction === "after" &&
|
||||
cursor !== undefined &&
|
||||
hasRows &&
|
||||
rows.length > 0 &&
|
||||
cursor.seq < state.minSeq - 1;
|
||||
const reset = staleCursor || gap;
|
||||
const page = state.projection.selectPage({
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows,
|
||||
bounds: window,
|
||||
direction: reset ? "tail" : direction,
|
||||
cursorSeq: cursor?.seq,
|
||||
@@ -144,11 +150,14 @@ export class InMemoryAgentTimelineStore {
|
||||
|
||||
getLastItem(agentId: string): AgentTimelineItem | null {
|
||||
const state = this.requireState(agentId);
|
||||
return state.projection.getLastItem(state.nextSeq - 1);
|
||||
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
|
||||
}
|
||||
|
||||
getLastAssistantMessage(agentId: string): string | null {
|
||||
return this.requireState(agentId).projection.getLastAssistantMessage();
|
||||
const row = this.requireState(agentId)
|
||||
.projection.getRows()
|
||||
.findLast((candidate) => candidate.item.type === "assistant_message");
|
||||
return row?.item.type === "assistant_message" ? row.item.text : null;
|
||||
}
|
||||
|
||||
private requireState(agentId: string): AgentTimelineState {
|
||||
|
||||
@@ -99,16 +99,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("mutable provider config owner", () => {
|
||||
test("publishes provider changes after commit and emits nothing on rollback", () => {
|
||||
test("publishes provider changes after commit and emits nothing on rollback", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 }));
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: { codex: { enabled: true } },
|
||||
providerOverrides: CONTROLLED_PROVIDERS,
|
||||
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
|
||||
});
|
||||
const cwd = path.resolve("/tmp/provider-config-owner");
|
||||
manager.getSnapshot(cwd);
|
||||
await manager.refreshSnapshotForCwd({ cwd });
|
||||
const events: string[] = [];
|
||||
manager.on("change", ({ current }) => events.push(current.cwd));
|
||||
let agentManagerState = manager.getAgentManagerProviderState();
|
||||
@@ -125,7 +126,7 @@ describe("mutable provider config owner", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
store.patch({ providers: { codex: { enabled: false } } });
|
||||
await store.patch({ providers: { codex: { enabled: false } } });
|
||||
expect(events).toEqual([cwd]);
|
||||
expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false });
|
||||
|
||||
@@ -133,7 +134,7 @@ describe("mutable provider config owner", () => {
|
||||
const unsubscribeFailure = store.onApply(() => {
|
||||
throw new Error("later owner failed");
|
||||
});
|
||||
expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow(
|
||||
await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow(
|
||||
"later owner failed",
|
||||
);
|
||||
unsubscribeFailure();
|
||||
@@ -150,7 +151,7 @@ describe("mutable provider config owner", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("does no provider work for unrelated CORS, Git, and app URL reloads", () => {
|
||||
test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const initial: PersistedConfig = {
|
||||
@@ -174,9 +175,10 @@ describe("mutable provider config owner", () => {
|
||||
});
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: { codex: { enabled: true } },
|
||||
providerOverrides: CONTROLLED_PROVIDERS,
|
||||
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
|
||||
});
|
||||
manager.getSnapshot("/tmp/provider-config-owner");
|
||||
await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" });
|
||||
const events: string[] = [];
|
||||
manager.on("change", ({ current }) => events.push(current.cwd));
|
||||
let registryUpdates = 0;
|
||||
@@ -200,7 +202,7 @@ describe("mutable provider config owner", () => {
|
||||
};
|
||||
writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8");
|
||||
|
||||
expect(store.reload().appliedPaths).toEqual([
|
||||
expect((await store.reload()).appliedPaths).toEqual([
|
||||
"app.baseUrl",
|
||||
"daemon.cors.allowedOrigins",
|
||||
"daemon.git.maxProcessConcurrency",
|
||||
@@ -245,7 +247,7 @@ describe("mutable provider config owner", () => {
|
||||
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1));
|
||||
expect(events).toEqual([]);
|
||||
|
||||
store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
|
||||
await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
|
||||
expect(events).toEqual(["loading:none"]);
|
||||
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2));
|
||||
|
||||
@@ -311,9 +313,9 @@ describe("mutable provider config owner", () => {
|
||||
expect(events).toEqual([]);
|
||||
|
||||
const before = manager.getSnapshot(cwd);
|
||||
expect(() =>
|
||||
await expect(
|
||||
store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }),
|
||||
).toThrow("later owner failed");
|
||||
).rejects.toThrow("later owner failed");
|
||||
expect(events).toEqual([]);
|
||||
expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]);
|
||||
expect(observedDuringApply[0]!.snapshot).toBe(before);
|
||||
|
||||
@@ -310,7 +310,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
});
|
||||
const own = { nested: { replace: "agent" } };
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "plugin-direct", cwd: "/tmp", providerOptions: own },
|
||||
{ provider: "plugin-direct", cwd: tmpdir(), providerOptions: own },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
@@ -56,6 +56,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as
|
||||
getDaemonTcpHost: null,
|
||||
serviceProxyPublicBaseUrl: null,
|
||||
resolveScriptHealth: null,
|
||||
getGlobalServicePorts: async () => undefined,
|
||||
logger,
|
||||
emit: () => {},
|
||||
publishStatusUpdate: () => {},
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
// Frozen pre-index page selection: differential oracle for retained projection tests.
|
||||
import type { AgentTimelineRow } from "../agent-timeline-store-types.js";
|
||||
import {
|
||||
TimelineProjection,
|
||||
projectTimelineRows,
|
||||
type TimelineProjectionEntry,
|
||||
type ProjectedTimelineRow,
|
||||
type TimelineLimitDirection,
|
||||
type ProjectedTimelinePageSelection,
|
||||
} from "../timeline-projection.js";
|
||||
|
||||
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): TimelineProjectionEntry[] {
|
||||
return rows.map((row) => {
|
||||
if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) };
|
||||
return {
|
||||
item: row.item,
|
||||
timestamp: row.timestamp,
|
||||
...(row.turnId ? { turnId: row.turnId } : {}),
|
||||
...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}),
|
||||
seqStart: row.seq,
|
||||
seqEnd: row.seq,
|
||||
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
|
||||
collapsed: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getTimelineBounds(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
): { minSeq: number; maxSeq: number } | null {
|
||||
const first = rows[0];
|
||||
const last = rows[rows.length - 1];
|
||||
if (!first || !last) {
|
||||
return null;
|
||||
}
|
||||
const entries = makeCanonicalEntries(rows);
|
||||
return {
|
||||
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
|
||||
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
|
||||
};
|
||||
}
|
||||
|
||||
function firstSourceSeqInRange(
|
||||
entry: TimelineProjectionEntry,
|
||||
startSeq: number,
|
||||
endSeq: number,
|
||||
): number | null {
|
||||
for (const range of entry.sourceSeqRanges) {
|
||||
const firstSeq = Math.max(range.startSeq, startSeq);
|
||||
if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ProjectedEntryCandidate {
|
||||
entry: TimelineProjectionEntry;
|
||||
index: number;
|
||||
firstSourceSeq: number;
|
||||
}
|
||||
|
||||
function selectProjectedEntriesAfter(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
startSeq: number;
|
||||
maxSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; endSeq: number | null } {
|
||||
const eligible = input.entries
|
||||
.map((entry, index) => ({
|
||||
entry,
|
||||
index,
|
||||
firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq),
|
||||
}))
|
||||
.filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null)
|
||||
.sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index);
|
||||
const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit);
|
||||
const selectedEntries = selected
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => candidate.entry);
|
||||
if (selectedEntries.length === 0) return { entries: [], endSeq: null };
|
||||
|
||||
const selectedRanges = selectedEntries
|
||||
.flatMap((entry) => entry.sourceSeqRanges)
|
||||
.sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
|
||||
// Wide projected entries can include future, discontiguous source ranges. The
|
||||
// page cursor advances only through source rows covered without a gap.
|
||||
let endSeq = input.startSeq - 1;
|
||||
for (const range of selectedRanges) {
|
||||
if (range.endSeq <= endSeq) continue;
|
||||
if (range.startSeq > endSeq + 1) break;
|
||||
endSeq = Math.min(range.endSeq, input.maxSeq);
|
||||
}
|
||||
|
||||
return {
|
||||
entries: selectedEntries,
|
||||
endSeq: endSeq >= input.startSeq ? endSeq : null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesBefore(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
endSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
|
||||
// Older history follows projected display order. Lifecycle updates can move an
|
||||
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
|
||||
// projected item on exactly one backward page.
|
||||
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
|
||||
const selected =
|
||||
input.limit === 0 || input.limit >= eligible.length
|
||||
? eligible
|
||||
: eligible.slice(eligible.length - input.limit);
|
||||
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
hasOlder: selected.length < eligible.length,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesTail(
|
||||
projectedAll: TimelineProjectionEntry[],
|
||||
limit: number,
|
||||
bounds: { minSeq: number; maxSeq: number },
|
||||
): ProjectedTimelinePageSelection {
|
||||
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
|
||||
// Include earlier display anchors with updates in the selected source window,
|
||||
// so a tail certifies contiguous coverage even with interleaved tool updates.
|
||||
for (let i = start - 1; i >= 0; i--) {
|
||||
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
|
||||
}
|
||||
const selected = projectedAll.slice(start);
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
endSeq: bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectReferenceProjectedTimelinePage(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
bounds?: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
limit?: number;
|
||||
}): ProjectedTimelinePageSelection {
|
||||
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows);
|
||||
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
||||
if (!bounds) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectedAll.length === 0) {
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq >= bounds.minSeq,
|
||||
hasNewer: cursorSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
if (input.direction === "before") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq > bounds.minSeq,
|
||||
hasNewer: cursorSeq <= bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.direction === "tail") {
|
||||
return selectProjectedEntriesTail(projectedAll, limit, bounds);
|
||||
}
|
||||
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries: projectedAll,
|
||||
startSeq,
|
||||
maxSeq: bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
if (endSeq < bounds.minSeq) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.startSeq,
|
||||
endSeq,
|
||||
hasOlder: selected.hasOlder,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
// Existing selection fixtures exercise the retained owner through its public API.
|
||||
export function selectProjectedTimelinePage(
|
||||
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0],
|
||||
) {
|
||||
const projection = new TimelineProjection(input.rows);
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 };
|
||||
const page = projection.selectPage({ ...input, bounds });
|
||||
return {
|
||||
...page,
|
||||
entries: page.entries.map((row) => {
|
||||
const { seq: _seq, ...entry } = row as ProjectedTimelineRow;
|
||||
return entry;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { selectProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentTimelineRow } from "./agent-manager.js";
|
||||
import {
|
||||
projectTimelineRows,
|
||||
selectProjectedTimelinePage,
|
||||
selectTimelineWindowByProjectedLimit,
|
||||
} from "./timeline-projection.js";
|
||||
|
||||
|
||||
@@ -295,43 +295,12 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
/** A retained item. `seq` is its latest source event, not its display anchor. */
|
||||
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
|
||||
|
||||
interface ProjectedTimelinePageOptions {
|
||||
bounds: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Owns projected state; source events are consumed, never retained. */
|
||||
export class TimelineProjection {
|
||||
private readonly rows: ProjectedTimelineRow[] = [];
|
||||
private readonly identities = new Map<string, number>();
|
||||
private lastAssistantRowIndex: number | null = null;
|
||||
// Disjoint source-coverage spans in display order. A tail starting inside a
|
||||
// span must include its first anchor to cover interleaved lifecycle updates.
|
||||
private readonly spans: { start: number; endSeq: number }[] = [];
|
||||
|
||||
private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = [];
|
||||
|
||||
constructor(rows: readonly AgentTimelineRow[] = []) {
|
||||
for (const row of rows) this.appendProjected(row);
|
||||
for (const [rowIndex, row] of this.rows.entries()) {
|
||||
for (const range of row.sourceSeqRanges) this.sourceRanges.push({ ...range, rowIndex });
|
||||
}
|
||||
this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq);
|
||||
}
|
||||
|
||||
append(row: AgentTimelineRow): void {
|
||||
const rowIndex = this.appendProjected(row);
|
||||
const last = this.sourceRanges.at(-1);
|
||||
if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) {
|
||||
last.endSeq = row.seq;
|
||||
} else {
|
||||
this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex });
|
||||
}
|
||||
}
|
||||
|
||||
private appendProjected(row: AgentTimelineRow): number {
|
||||
const entry = makeCanonicalEntries([row])[0];
|
||||
const identity = timelineItemIdentity(row.item);
|
||||
const index = identity === null ? undefined : this.identities.get(identity);
|
||||
@@ -339,8 +308,7 @@ export class TimelineProjection {
|
||||
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
|
||||
if (merged && index !== undefined) {
|
||||
this.rows[index] = { ...merged, seq: merged.seqEnd };
|
||||
this.extendSpan(index);
|
||||
return index;
|
||||
return;
|
||||
}
|
||||
const previous = this.rows.at(-1);
|
||||
const adjacent = previous
|
||||
@@ -349,159 +317,16 @@ export class TimelineProjection {
|
||||
if (previous && adjacent.length === 1) {
|
||||
const mergedText = adjacent[0];
|
||||
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
|
||||
this.extendSpan(this.rows.length - 1);
|
||||
return this.rows.length - 1;
|
||||
return;
|
||||
}
|
||||
if (identity !== null) this.identities.set(identity, this.rows.length);
|
||||
if (entry.item.type === "assistant_message") this.lastAssistantRowIndex = this.rows.length;
|
||||
this.rows.push({ ...entry, seq: entry.seqEnd });
|
||||
const last = this.spans.at(-1);
|
||||
if (last && last.endSeq >= entry.seqStart) {
|
||||
last.endSeq = Math.max(last.endSeq, entry.seqEnd);
|
||||
} else {
|
||||
this.spans.push({ start: this.rows.length - 1, endSeq: entry.seqEnd });
|
||||
}
|
||||
return this.rows.length - 1;
|
||||
}
|
||||
|
||||
private spanIndex(rowIndex: number): number {
|
||||
let low = 0;
|
||||
let high = this.spans.length;
|
||||
while (low + 1 < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.spans[middle].start <= rowIndex) low = middle;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
private extendSpan(rowIndex: number): void {
|
||||
const index = this.spanIndex(rowIndex);
|
||||
const span = this.spans[index];
|
||||
span.endSeq = Math.max(span.endSeq, this.rows[rowIndex].seqEnd);
|
||||
let end = index + 1;
|
||||
while (end < this.spans.length && this.rows[this.spans[end].start].seqStart <= span.endSeq) {
|
||||
span.endSeq = Math.max(span.endSeq, this.spans[end].endSeq);
|
||||
end++;
|
||||
}
|
||||
this.spans.splice(index + 1, end - index - 1);
|
||||
}
|
||||
|
||||
getRows(): ProjectedTimelineRow[] {
|
||||
return this.rows.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
if (this.rows.length === 0) {
|
||||
const cursor =
|
||||
input.cursorSeq ??
|
||||
(input.direction === "before" ? input.bounds.maxSeq + 1 : input.bounds.minSeq - 1);
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder:
|
||||
input.direction === "after"
|
||||
? cursor >= input.bounds.minSeq
|
||||
: input.direction === "before" && cursor > input.bounds.minSeq,
|
||||
hasNewer:
|
||||
input.direction === "after"
|
||||
? cursor < input.bounds.maxSeq
|
||||
: input.direction === "before" && cursor <= input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
switch (input.direction) {
|
||||
case "tail":
|
||||
return this.selectTail(input);
|
||||
case "after":
|
||||
return this.selectAfter(input);
|
||||
case "before":
|
||||
return this.selectBefore(input);
|
||||
}
|
||||
}
|
||||
|
||||
private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit);
|
||||
const start = this.spans[this.spanIndex(requestedStart)].start;
|
||||
return {
|
||||
entries: this.rows.slice(start),
|
||||
startSeq: this.rows[start].seqStart,
|
||||
endSeq: input.bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const startSeq = Math.max(
|
||||
input.bounds.minSeq,
|
||||
(input.cursorSeq ?? input.bounds.minSeq - 1) + 1,
|
||||
);
|
||||
let low = 0;
|
||||
let high = this.sourceRanges.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.sourceRanges[middle].endSeq < startSeq) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const indexes = new Set<number>();
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
for (let i = low; i < this.sourceRanges.length; i++) {
|
||||
const range = this.sourceRanges[i];
|
||||
if (range.startSeq > input.bounds.maxSeq) break;
|
||||
indexes.add(range.rowIndex);
|
||||
if (limit > 0 && indexes.size === limit) break;
|
||||
}
|
||||
const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries,
|
||||
startSeq,
|
||||
maxSeq: input.bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
hasOlder: startSeq > input.bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1);
|
||||
if (endSeq >= input.bounds.minSeq) {
|
||||
let low = 0;
|
||||
let high = this.rows.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.rows[middle].seqStart <= endSeq) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const start = limit === 0 ? 0 : Math.max(0, low - limit);
|
||||
return {
|
||||
entries: this.rows.slice(start, low),
|
||||
startSeq: this.rows[start]?.seqStart ?? null,
|
||||
endSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: endSeq < input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
|
||||
}
|
||||
|
||||
getLastItem(seq: number): AgentTimelineItem | null {
|
||||
const last = this.sourceRanges.at(-1);
|
||||
return last?.endSeq === seq ? this.rows[last.rowIndex].item : null;
|
||||
}
|
||||
|
||||
getLastAssistantMessage(): string | null {
|
||||
const row = this.lastAssistantRowIndex === null ? null : this.rows[this.lastAssistantRowIndex];
|
||||
return row?.item.type === "assistant_message" ? row.item.text : null;
|
||||
}
|
||||
|
||||
enrichSubmittedUserMessage(
|
||||
clientMessageId: string,
|
||||
providerMessageId: string,
|
||||
@@ -619,6 +444,21 @@ export function selectTimelineWindowByProjectedLimit(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function getTimelineBounds(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
): { minSeq: number; maxSeq: number } | null {
|
||||
const first = rows[0];
|
||||
const last = rows[rows.length - 1];
|
||||
if (!first || !last) {
|
||||
return null;
|
||||
}
|
||||
const entries = makeCanonicalEntries(rows);
|
||||
return {
|
||||
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
|
||||
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
|
||||
};
|
||||
}
|
||||
|
||||
function firstSourceSeqInRange(
|
||||
entry: TimelineProjectionEntry,
|
||||
startSeq: number,
|
||||
@@ -675,6 +515,142 @@ function selectProjectedEntriesAfter(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesBefore(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
endSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
|
||||
// Older history follows projected display order. Lifecycle updates can move an
|
||||
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
|
||||
// projected item on exactly one backward page.
|
||||
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
|
||||
const selected =
|
||||
input.limit === 0 || input.limit >= eligible.length
|
||||
? eligible
|
||||
: eligible.slice(eligible.length - input.limit);
|
||||
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
hasOlder: selected.length < eligible.length,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesTail(
|
||||
projectedAll: TimelineProjectionEntry[],
|
||||
limit: number,
|
||||
bounds: { minSeq: number; maxSeq: number },
|
||||
): ProjectedTimelinePageSelection {
|
||||
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
|
||||
// Include earlier display anchors with updates in the selected source window,
|
||||
// so a tail certifies contiguous coverage even with interleaved tool updates.
|
||||
for (let i = start - 1; i >= 0; i--) {
|
||||
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
|
||||
}
|
||||
const selected = projectedAll.slice(start);
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
endSeq: bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectProjectedTimelinePage(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
bounds?: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
limit?: number;
|
||||
}): ProjectedTimelinePageSelection {
|
||||
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows);
|
||||
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
||||
if (!bounds) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectedAll.length === 0) {
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq >= bounds.minSeq,
|
||||
hasNewer: cursorSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
if (input.direction === "before") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq > bounds.minSeq,
|
||||
hasNewer: cursorSeq <= bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.direction === "tail") {
|
||||
return selectProjectedEntriesTail(projectedAll, limit, bounds);
|
||||
}
|
||||
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries: projectedAll,
|
||||
startSeq,
|
||||
maxSeq: bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
if (endSeq < bounds.minSeq) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.startSeq,
|
||||
endSeq,
|
||||
hasOlder: selected.hasOlder,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
|
||||
* Used by callers that only have items in hand (e.g. MCP tools reading
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js";
|
||||
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(["EPERM", "EACCES", "EBUSY"])(
|
||||
"commits a completed receipt after transient Windows %s replace failures",
|
||||
async (code) => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
await writeJsonFileAtomic(file, { state: "pending" });
|
||||
let failures = 3;
|
||||
let elapsed = 0;
|
||||
await writeJsonFileAtomic(
|
||||
file,
|
||||
{ state: "completed" },
|
||||
{
|
||||
platform: "win32",
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => {
|
||||
elapsed += milliseconds;
|
||||
},
|
||||
rename: async (source, destination) => {
|
||||
if (failures-- > 0) {
|
||||
expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({
|
||||
state: "pending",
|
||||
});
|
||||
expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" });
|
||||
throw Object.assign(new Error("destination held open"), { code });
|
||||
}
|
||||
await fs.rename(source, destination);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" });
|
||||
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
||||
expect(elapsed).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["EPERM", "EACCES", "EBUSY"])(
|
||||
"preserves the previous file and removes the temp file when Windows %s persists",
|
||||
async (code) => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
await writeJsonFileAtomic(file, { state: "pending" });
|
||||
const error = Object.assign(new Error("destination held open"), { code });
|
||||
const waits: number[] = [];
|
||||
let elapsed = 0;
|
||||
const attempts: number[] = [];
|
||||
await expect(
|
||||
writeJsonFileAtomic(
|
||||
file,
|
||||
{ state: "completed" },
|
||||
{
|
||||
platform: "win32",
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
elapsed += milliseconds;
|
||||
},
|
||||
rename: async () => {
|
||||
attempts.push(elapsed);
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toBe(error);
|
||||
expect(elapsed).toBe(1000);
|
||||
expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]);
|
||||
expect(Math.max(...waits)).toBe(100);
|
||||
expect(attempts.every((time) => time < 1000)).toBe(true);
|
||||
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
|
||||
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["linux", "EPERM"],
|
||||
["darwin", "EACCES"],
|
||||
["linux", "EBUSY"],
|
||||
["win32", "ENOENT"],
|
||||
["win32", "ENOSPC"],
|
||||
["win32", "EXDEV"],
|
||||
] as const)("does not retry %s %s failures", async (platform, code) => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
await writeJsonFileAtomic(file, { state: "pending" });
|
||||
const error = Object.assign(new Error("rename failed"), { code });
|
||||
let attempts = 0;
|
||||
const waits: number[] = [];
|
||||
await expect(
|
||||
writeFileAtomic(file, "replacement", {
|
||||
platform,
|
||||
now: () => 0,
|
||||
sleep: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
},
|
||||
rename: async () => {
|
||||
attempts++;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
expect(attempts).toBe(1);
|
||||
expect(waits).toEqual([]);
|
||||
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
|
||||
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
||||
});
|
||||
|
||||
it("does not retry after a rename consumes the Windows retry budget", async () => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" });
|
||||
let elapsed = 0;
|
||||
const waits: number[] = [];
|
||||
await expect(
|
||||
writeFileAtomic(file, "replacement", {
|
||||
platform: "win32",
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
},
|
||||
rename: async () => {
|
||||
elapsed += 1000;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
expect(waits).toEqual([]);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates parent directories and replaces binary and JSON files through the default runtime", async () => {
|
||||
const file = path.join(directory, "nested", "file");
|
||||
const bytes = Buffer.from([0, 255, 128, 1]);
|
||||
await writeFileAtomic(file, bytes);
|
||||
expect(await fs.readFile(file)).toEqual(bytes);
|
||||
await writeJsonFileAtomic(file, { state: "completed" });
|
||||
expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}');
|
||||
expect(await fs.readdir(path.dirname(file))).toEqual(["file"]);
|
||||
});
|
||||
@@ -1,26 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
interface AtomicFileRuntime {
|
||||
platform: NodeJS.Platform;
|
||||
rename: (source: string, destination: string) => Promise<void>;
|
||||
now: () => number;
|
||||
sleep: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const runtime: AtomicFileRuntime = {
|
||||
platform: process.platform,
|
||||
rename: fs.rename,
|
||||
now: () => performance.now(),
|
||||
sleep,
|
||||
};
|
||||
|
||||
export async function writeFileAtomic(
|
||||
filePath: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
operations: AtomicFileRuntime = runtime,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = path.join(
|
||||
@@ -29,48 +13,13 @@ export async function writeFileAtomic(
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(tempPath, data, "utf8");
|
||||
await replaceFile(tempPath, filePath, operations);
|
||||
await fs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
await fs.rm(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceFile(
|
||||
source: string,
|
||||
destination: string,
|
||||
operations: AtomicFileRuntime,
|
||||
): Promise<void> {
|
||||
// Windows readers/scanners can briefly deny replacement. Yield with backoff,
|
||||
// but keep interactive persistence failures bounded to a one-second retry budget.
|
||||
const deadline = operations.now() + 1000;
|
||||
let delay = 10;
|
||||
for (;;) {
|
||||
try {
|
||||
await operations.rename(source, destination);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
operations.platform !== "win32" ||
|
||||
!(error instanceof Error) ||
|
||||
!("code" in error) ||
|
||||
!["EPERM", "EACCES", "EBUSY"].includes(String(error.code))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const remaining = deadline - operations.now();
|
||||
if (remaining <= 0) throw error;
|
||||
await operations.sleep(Math.min(delay, remaining));
|
||||
if (operations.now() >= deadline) throw error;
|
||||
delay = Math.min(delay * 2, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFileAtomic(
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
operations: AtomicFileRuntime = runtime,
|
||||
): Promise<void> {
|
||||
await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations);
|
||||
export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
|
||||
await writeFileAtomic(filePath, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
@@ -141,11 +141,11 @@ function liveCleanupHost() {
|
||||
test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => {
|
||||
const host = liveCleanupHost();
|
||||
const other = liveCleanupHost();
|
||||
host.store.patch({ idleRuntimeCleanup: false });
|
||||
await host.store.patch({ idleRuntimeCleanup: false });
|
||||
expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false);
|
||||
expect((await host.sweep()).collected).toEqual([]);
|
||||
expect((await other.sweep()).collected).toHaveLength(1);
|
||||
host.store.patch({ idleRuntimeCleanup: true });
|
||||
await host.store.patch({ idleRuntimeCleanup: true });
|
||||
host.protect(true);
|
||||
expect((await host.sweep()).collected).toEqual([]);
|
||||
host.protect(false);
|
||||
@@ -160,7 +160,7 @@ test("bootstrap's config-file reload binding disables and enables eligible colle
|
||||
path.join(host.home, "config.json"),
|
||||
JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }),
|
||||
);
|
||||
expect(host.store.reload()).toEqual({
|
||||
expect(await host.store.reload()).toEqual({
|
||||
appliedPaths: ["agents.idleRuntimeCleanup"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -173,12 +173,12 @@ test.each([false, true])(
|
||||
"failed config apply restores prior idle cleanup behavior (previous: %s)",
|
||||
async (previous) => {
|
||||
const host = liveCleanupHost();
|
||||
host.store.patch({ idleRuntimeCleanup: previous });
|
||||
await host.store.patch({ idleRuntimeCleanup: previous });
|
||||
const persistedBefore = loadPersistedConfig(host.home);
|
||||
const refuse = host.store.onApply(() => {
|
||||
throw new Error("Another live owner refused");
|
||||
});
|
||||
expect(() => host.store.patch({ idleRuntimeCleanup: !previous })).toThrow(
|
||||
await expect(host.store.patch({ idleRuntimeCleanup: !previous })).rejects.toThrow(
|
||||
"Another live owner refused",
|
||||
);
|
||||
expect(host.store.get().idleRuntimeCleanup).toBe(previous);
|
||||
@@ -191,6 +191,6 @@ test.each([false, true])(
|
||||
test("disposing bootstrap's binding stops configuration delivery", async () => {
|
||||
const host = liveCleanupHost();
|
||||
host.unsubscribe();
|
||||
host.store.patch({ idleRuntimeCleanup: false });
|
||||
await host.store.patch({ idleRuntimeCleanup: false });
|
||||
expect((await host.sweep()).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -161,7 +161,11 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { ScheduleService } from "./schedule/service.js";
|
||||
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js";
|
||||
import { createOrchestrationSkills } from "./orchestration-skills/index.js";
|
||||
import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js";
|
||||
import {
|
||||
resolveReloadableConfig,
|
||||
type ResolvedDaemonSettings,
|
||||
type CliConfigOverrides,
|
||||
} from "./config.js";
|
||||
import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js";
|
||||
import { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js";
|
||||
@@ -196,7 +200,7 @@ import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||
import { type PersistedConfig } from "./persisted-config.js";
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
||||
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
||||
@@ -551,7 +555,7 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
|
||||
return config.trustedProxies ?? ["loopback"];
|
||||
}
|
||||
|
||||
function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] {
|
||||
function createInitialRelayConfig(config: ResolvedDaemonSettings): MutableDaemonConfig["relay"] {
|
||||
return {
|
||||
enabled: config.relayEnabled ?? true,
|
||||
requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false,
|
||||
@@ -562,7 +566,7 @@ function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv {
|
||||
return config.configReload?.env ?? process.env;
|
||||
}
|
||||
|
||||
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
|
||||
function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): MutableDaemonConfig {
|
||||
const providers = config.providerOverrides ?? {};
|
||||
|
||||
const initialConfig: MutableDaemonConfig = {
|
||||
@@ -638,14 +642,14 @@ export async function createPaseoDaemon(
|
||||
}),
|
||||
reloadSource: {
|
||||
resolve: (persisted) => {
|
||||
const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, {
|
||||
const reloaded = resolveReloadableConfig(persisted, {
|
||||
env: resolveReloadEnv(config),
|
||||
cli: config.configReload?.cli,
|
||||
relayEnabledFallback: config.configReload?.relayEnabledFallback,
|
||||
});
|
||||
return {
|
||||
mutable: createInitialMutableDaemonConfig(reloaded),
|
||||
overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [],
|
||||
overrideControlledPaths: reloaded.overrideControlledPaths,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -1536,7 +1540,7 @@ export async function createPaseoDaemon(
|
||||
spawnWorkspaceScript,
|
||||
assertAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId),
|
||||
globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts,
|
||||
getGlobalServicePorts: () => daemonConfigStore.readServicePorts(),
|
||||
}),
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
|
||||
@@ -4,7 +4,12 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js";
|
||||
import {
|
||||
loadConfig,
|
||||
resolveBundledWebUiDistDir,
|
||||
resolveConfigFromPersisted,
|
||||
resolveReloadableConfig,
|
||||
} from "./config.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
@@ -14,6 +19,30 @@ describe("server config", () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("resolves reloadable settings without resolving the startup password", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-reload-"));
|
||||
roots.push(paseoHome);
|
||||
const env = { PASEO_PASSWORD: "synthetic-audit-password" };
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
const startup = resolveConfigFromPersisted(paseoHome, persisted, { env });
|
||||
const started = performance.now();
|
||||
const timer = new Promise<number>((resolve) =>
|
||||
setTimeout(() => resolve(performance.now() - started), 0),
|
||||
);
|
||||
const reloaded = resolveReloadableConfig(
|
||||
{
|
||||
...persisted,
|
||||
daemon: { ...persisted.daemon, browserTools: { enabled: true } },
|
||||
},
|
||||
{ env },
|
||||
);
|
||||
const gap = await timer;
|
||||
expect(reloaded).not.toHaveProperty("auth");
|
||||
expect(reloaded.browserToolsEnabled).toBe(true);
|
||||
expect(startup.auth?.password).toBeTypeOf("string");
|
||||
expect(gap).toBeLessThan(50);
|
||||
});
|
||||
|
||||
test("records when the daemon is managed by Paseo Desktop", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-"));
|
||||
roots.push(paseoHome);
|
||||
|
||||
@@ -568,31 +568,43 @@ interface ResolveConfigFromPersistedOptions {
|
||||
relayEnabledFallback?: boolean;
|
||||
}
|
||||
|
||||
// Startup values, including authentication, never enter runtime settings resolution.
|
||||
export type ResolvedDaemonSettings = Pick<
|
||||
PaseoDaemonConfig,
|
||||
| "corsAllowedOrigins"
|
||||
| "hostnames"
|
||||
| "trustedProxies"
|
||||
| "mcpEnabled"
|
||||
| "mcpInjectIntoAgents"
|
||||
| "browserToolsEnabled"
|
||||
| "git"
|
||||
| "autoArchiveAfterMerge"
|
||||
| "enableTerminalAgentHooks"
|
||||
| "appendSystemPrompt"
|
||||
| "terminalProfiles"
|
||||
| "agentProfiles"
|
||||
| "skillSelection"
|
||||
| "pluginsEnabled"
|
||||
| "plugins"
|
||||
| "relayEnabled"
|
||||
| "relayRequireProtectedSecurity"
|
||||
| "appBaseUrl"
|
||||
| "idleRuntimeCleanup"
|
||||
| "providerCatalogRefreshTimeoutMs"
|
||||
| "metadataGeneration"
|
||||
| "providerOverrides"
|
||||
>;
|
||||
|
||||
export function resolveConfigFromPersisted(
|
||||
paseoHome: string,
|
||||
persisted: PersistedConfig,
|
||||
options?: ResolveConfigFromPersistedOptions,
|
||||
): PaseoDaemonConfig {
|
||||
const resolvedOptions = options ?? {};
|
||||
const env = configurationEnvironment(resolvedOptions.env ?? process.env);
|
||||
const cli = resolvedOptions.cli;
|
||||
const env = configurationEnvironment(options?.env ?? process.env);
|
||||
const cli = options?.cli;
|
||||
const relayEnabledFallback =
|
||||
resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
|
||||
|
||||
const listen = resolveListenAddress(env, cli, persisted);
|
||||
const {
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
browserToolsEnabled,
|
||||
autoArchiveAfterMerge,
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
agentProfiles,
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
appBaseUrl,
|
||||
} = resolveStaticLoadConfigSettings(env, cli, persisted);
|
||||
|
||||
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
|
||||
const { overrideControlledPaths, ...settings } = resolveReloadableConfig(persisted, options);
|
||||
const relay = resolveRelayConfig({
|
||||
env,
|
||||
persisted,
|
||||
@@ -600,42 +612,14 @@ export function resolveConfigFromPersisted(
|
||||
cliRelayUseTls: cli?.relayUseTls,
|
||||
enabledFallback: relayEnabledFallback,
|
||||
});
|
||||
const serviceProxy = resolveServiceProxyConfig(env, persisted);
|
||||
const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted);
|
||||
|
||||
const { openai, speech } = resolveSpeechConfig({
|
||||
paseoHome,
|
||||
env,
|
||||
persisted,
|
||||
});
|
||||
|
||||
const { openai, speech } = resolveSpeechConfig({ paseoHome, env, persisted });
|
||||
const voiceLlm = resolveVoiceLlmConfig(env, persisted);
|
||||
const providerOverrides = extractProviderOverrides(
|
||||
persisted.agents?.providers as Record<string, unknown> | undefined,
|
||||
);
|
||||
|
||||
const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, persisted);
|
||||
|
||||
return {
|
||||
listen,
|
||||
...settings,
|
||||
listen: resolveListenAddress(env, cli, persisted),
|
||||
paseoHome,
|
||||
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
|
||||
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
browserToolsEnabled,
|
||||
git: resolveGitProcessConfig(env, persisted),
|
||||
autoArchiveAfterMerge,
|
||||
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
agentProfiles,
|
||||
skillSelection: persisted.agents?.skills?.selection,
|
||||
pluginsEnabled: persisted.pluginsEnabled ?? false,
|
||||
plugins: persisted.plugins,
|
||||
pluginRegistries: persisted.pluginRegistries,
|
||||
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
@@ -643,27 +627,20 @@ export function resolveConfigFromPersisted(
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
staticDir: "public",
|
||||
agentClients: {},
|
||||
relayEnabled: relay.enabled,
|
||||
relayEnabledMutable: relay.enabledMutable,
|
||||
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
|
||||
relayEndpoint: relay.endpoint,
|
||||
relayPublicEndpoint: relay.publicEndpoint,
|
||||
relayUseTls: relay.useTls,
|
||||
relayPublicUseTls: relay.publicUseTls,
|
||||
serviceProxy,
|
||||
webUi,
|
||||
appBaseUrl,
|
||||
serviceProxy: resolveServiceProxyConfig(env, persisted),
|
||||
webUi: resolveWebUiConfig(paseoHome, env, cli, persisted),
|
||||
auth: resolveAuthConfig(env, persisted),
|
||||
openai,
|
||||
speech,
|
||||
voiceLlmProvider: voiceLlm.provider,
|
||||
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
|
||||
voiceLlmModel: voiceLlm.model,
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides,
|
||||
agentProviderSettings: extractAgentProviderSettings(settings.providerOverrides),
|
||||
log: resolveLogConfigFromEnv(env, persisted),
|
||||
configReload: {
|
||||
env: { ...env },
|
||||
@@ -675,6 +652,40 @@ export function resolveConfigFromPersisted(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveReloadableConfig(
|
||||
persisted: PersistedConfig,
|
||||
options?: ResolveConfigFromPersistedOptions,
|
||||
): ResolvedDaemonSettings & { overrideControlledPaths: string[] } {
|
||||
const env = configurationEnvironment(options?.env ?? process.env);
|
||||
const cli = options?.cli;
|
||||
const relay = resolveRelayConfig({
|
||||
env,
|
||||
persisted,
|
||||
cliRelayEnabled: cli?.relayEnabled,
|
||||
cliRelayUseTls: cli?.relayUseTls,
|
||||
enabledFallback:
|
||||
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined,
|
||||
});
|
||||
return {
|
||||
...resolveStaticLoadConfigSettings(env, cli, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
git: resolveGitProcessConfig(env, persisted),
|
||||
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
|
||||
skillSelection: persisted.agents?.skills?.selection,
|
||||
pluginsEnabled: persisted.pluginsEnabled ?? false,
|
||||
plugins: persisted.plugins,
|
||||
relayEnabled: relay.enabled,
|
||||
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides: extractProviderOverrides(
|
||||
persisted.agents?.providers as Record<string, unknown> | undefined,
|
||||
),
|
||||
overrideControlledPaths: resolveOverrideControlledPaths(env, cli, persisted),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(
|
||||
paseoHome: string,
|
||||
options?: Omit<ResolveConfigFromPersistedOptions, "relayEnabledFallback">,
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("DaemonConfigStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("patch persists relay state and emits its field change", () => {
|
||||
test("patch persists relay state and emits its field change", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -117,13 +117,13 @@ describe("DaemonConfigStore", () => {
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("relay.enabled", (value) => changes.push(value));
|
||||
|
||||
store.patch({ relay: { enabled: true } });
|
||||
await store.patch({ relay: { enabled: true } });
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists the relay protected security requirement independently of relay enablement", () => {
|
||||
test("patch persists the relay protected security requirement independently of relay enablement", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(
|
||||
@@ -145,14 +145,14 @@ describe("DaemonConfigStore", () => {
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value));
|
||||
|
||||
store.patch({ relay: { requireProtectedSecurity: true } });
|
||||
await store.patch({ relay: { requireProtectedSecurity: true } });
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true });
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true);
|
||||
});
|
||||
|
||||
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
|
||||
test("patch round-trips agent profiles through the strictly-parsed persisted config", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -166,7 +166,7 @@ describe("DaemonConfigStore", () => {
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
agentProfiles: [
|
||||
{
|
||||
id: "profile_ui",
|
||||
@@ -198,7 +198,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(store.get().agentProfiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("patch replaces the whole agent profile list rather than merging entries", () => {
|
||||
test("patch replaces the whole agent profile list rather than merging entries", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -216,13 +216,13 @@ describe("DaemonConfigStore", () => {
|
||||
],
|
||||
});
|
||||
|
||||
store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
|
||||
await store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
|
||||
|
||||
expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rolls back config when a field transition fails", () => {
|
||||
test("rolls back config when a field transition fails", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -241,14 +241,14 @@ describe("DaemonConfigStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
|
||||
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
|
||||
"Relay transport failed to start",
|
||||
);
|
||||
expect(store.get().relay?.enabled).toBe(false);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("rolls back live owners when a later transactional owner fails", () => {
|
||||
test("rolls back live owners when a later transactional owner fails", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -272,7 +272,7 @@ describe("DaemonConfigStore", () => {
|
||||
throw new Error("Provider refresh failed");
|
||||
});
|
||||
|
||||
expect(() => store.patch({ browserTools: { enabled: true } })).toThrow(
|
||||
await expect(store.patch({ browserTools: { enabled: true } })).rejects.toThrow(
|
||||
"Provider refresh failed",
|
||||
);
|
||||
expect(browserToolsEnabled).toBe(false);
|
||||
@@ -280,7 +280,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.browserTools?.enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects relay patches when a launch override owns the setting", () => {
|
||||
test("rejects relay patches when a launch override owns the setting", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(
|
||||
@@ -299,12 +299,12 @@ describe("DaemonConfigStore", () => {
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
|
||||
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
|
||||
"Relay is controlled by a daemon launch override",
|
||||
);
|
||||
});
|
||||
|
||||
test("unrelated patches do not persist a one-launch relay override", () => {
|
||||
test("unrelated patches do not persist a one-launch relay override", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
@@ -331,12 +331,12 @@ describe("DaemonConfigStore", () => {
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
store.patch({ browserTools: { enabled: true } });
|
||||
await store.patch({ browserTools: { enabled: true } });
|
||||
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("unrelated patches persist only requested file intent", () => {
|
||||
test("unrelated patches persist only requested file intent", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const before = loadPersistedConfig(paseoHome);
|
||||
@@ -362,7 +362,7 @@ describe("DaemonConfigStore", () => {
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
appendSystemPrompt: "Only this field",
|
||||
// Reload-only runtime state is accepted as unknown wire data for forward
|
||||
// compatibility but is not part of the patch capability.
|
||||
@@ -376,7 +376,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists provider enabled flags into config.json", () => {
|
||||
test("patch persists provider enabled flags into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -417,7 +417,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
gemini: { enabled: false },
|
||||
},
|
||||
@@ -432,7 +432,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists provider Paseo-tool policy without changing availability", () => {
|
||||
test("patch persists provider Paseo-tool policy without changing availability", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
writeFileSync(
|
||||
@@ -449,14 +449,14 @@ describe("DaemonConfigStore", () => {
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
claude: {
|
||||
paseoTools: { enabled: true, disabledTools: ["list_agents"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
claude: {
|
||||
paseoTools: { disabledTools: ["create_agent"] },
|
||||
@@ -474,7 +474,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch removes provider entries from config.json", () => {
|
||||
test("patch removes provider entries from config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -519,7 +519,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
const next = await store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.providers.gemini).toBeUndefined();
|
||||
expect(next.providers.claude).toEqual({ enabled: false });
|
||||
@@ -528,7 +528,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.agents?.providers?.claude).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
test("patch removes the providers object when the last provider is deleted", () => {
|
||||
test("patch removes the providers object when the last provider is deleted", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -567,13 +567,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ removeProviders: ["gemini"] });
|
||||
await store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.providers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("patch removes deleted providers from metadata generation", () => {
|
||||
test("patch removes deleted providers from metadata generation", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -629,7 +629,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
const next = await store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.metadataGeneration.providers).toEqual([{ provider: "claude", model: "haiku" }]);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
@@ -638,7 +638,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists provider removal when in-memory config is already clean", () => {
|
||||
test("patch persists provider removal when in-memory config is already clean", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -680,7 +680,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
const next = await store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.providers.gemini).toBeUndefined();
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
@@ -688,7 +688,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
|
||||
});
|
||||
|
||||
test("patch persists append system prompt into config.json", () => {
|
||||
test("patch persists append system prompt into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -706,7 +706,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
});
|
||||
|
||||
@@ -714,7 +714,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
|
||||
test("patch persists browser tools opt-in into config.json", () => {
|
||||
test("patch persists browser tools opt-in into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -731,13 +731,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ browserTools: { enabled: true } });
|
||||
await store.patch({ browserTools: { enabled: true } });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.daemon?.browserTools).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
test("patch persists provider additional models into config.json", () => {
|
||||
test("patch persists provider additional models into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -755,7 +755,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
claude: {
|
||||
additionalModels: [
|
||||
@@ -779,7 +779,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists daemon append system prompt into config.json", () => {
|
||||
test("patch persists daemon append system prompt into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -797,7 +797,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
});
|
||||
|
||||
@@ -805,7 +805,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
|
||||
test("patch persists enable terminal agent hooks into config.json", () => {
|
||||
test("patch persists enable terminal agent hooks into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -822,13 +822,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ enableTerminalAgentHooks: true });
|
||||
await store.patch({ enableTerminalAgentHooks: true });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.daemon?.enableTerminalAgentHooks).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists metadata generation providers into config.json", () => {
|
||||
test("patch persists metadata generation providers into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -846,7 +846,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
@@ -864,7 +864,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists clearing metadata generation providers into config.json", () => {
|
||||
test("patch persists clearing metadata generation providers into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -899,13 +899,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ metadataGeneration: { providers: [] } });
|
||||
await store.patch({ metadataGeneration: { providers: [] } });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
|
||||
});
|
||||
|
||||
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", () => {
|
||||
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
writeFileSync(
|
||||
@@ -930,7 +930,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(store.patch({ idleRuntimeCleanup: false }).idleRuntimeCleanup).toBe(false);
|
||||
expect((await store.patch({ idleRuntimeCleanup: false })).idleRuntimeCleanup).toBe(false);
|
||||
|
||||
expect(loadPersistedConfig(paseoHome).agents).toEqual({
|
||||
metadataGeneration: { providers: [{ provider: "claude" }] },
|
||||
@@ -938,11 +938,11 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
|
||||
|
||||
store.patch({ idleRuntimeCleanup: true });
|
||||
await store.patch({ idleRuntimeCleanup: true });
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists custom ACP provider overrides into config.json", () => {
|
||||
test("patch persists custom ACP provider overrides into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -960,7 +960,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
"paseo-e2e-acp": {
|
||||
extends: "acp",
|
||||
@@ -1028,7 +1028,49 @@ describe("DaemonConfigStore reload", () => {
|
||||
writeFileSync(path.join(paseoHome, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test("applies mutable edits and reports startup-only edits", () => {
|
||||
test("applies overlapping plugin mutations to the latest committed sources", async () => {
|
||||
const { store } = createReloadableStore({
|
||||
initialPersisted: {
|
||||
plugins: {
|
||||
a: { source: "directory", path: "/a", enabled: false },
|
||||
b: { source: "directory", path: "/b", enabled: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const enable = store.patch((current) => ({
|
||||
plugins: {
|
||||
...current.plugins,
|
||||
a: { ...current.plugins.a!, enabled: true },
|
||||
},
|
||||
}));
|
||||
const disable = store.patch((current) => ({
|
||||
plugins: {
|
||||
...current.plugins,
|
||||
b: { ...current.plugins.b!, enabled: false },
|
||||
},
|
||||
}));
|
||||
await Promise.all([enable, disable]);
|
||||
expect(store.get().plugins).toEqual({
|
||||
a: { source: "directory", path: "/a", enabled: true },
|
||||
b: { source: "directory", path: "/b", enabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("serializes overlapping patches before committing their combined state", async () => {
|
||||
const { paseoHome, store } = createReloadableStore();
|
||||
const first = store.patch({ appendSystemPrompt: "first" });
|
||||
const second = store.patch({ browserTools: { enabled: true } });
|
||||
expect(store.get().appendSystemPrompt).toBe("");
|
||||
await Promise.all([first, second]);
|
||||
expect(store.get().appendSystemPrompt).toBe("first");
|
||||
expect(store.get().browserTools.enabled).toBe(true);
|
||||
expect(loadPersistedConfig(paseoHome).daemon).toMatchObject({
|
||||
appendSystemPrompt: "first",
|
||||
browserTools: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("applies mutable edits and reports startup-only edits", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
writeConfig(paseoHome, {
|
||||
...persisted,
|
||||
@@ -1040,7 +1082,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [
|
||||
"daemon.browserTools.enabled",
|
||||
"daemon.git.maxProcessConcurrency",
|
||||
@@ -1053,13 +1095,13 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 });
|
||||
});
|
||||
|
||||
test("reloads idle runtime cleanup in both directions without requiring a restart", () => {
|
||||
test("reloads idle runtime cleanup in both directions without requiring a restart", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value));
|
||||
for (const enabled of [false, true]) {
|
||||
writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } });
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["agents.idleRuntimeCleanup"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1067,8 +1109,8 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().idleRuntimeCleanup).toBe(enabled);
|
||||
}
|
||||
expect(changes).toEqual([false, true]);
|
||||
store.patch({ idleRuntimeCleanup: false });
|
||||
expect(store.reload()).toEqual({
|
||||
await store.patch({ idleRuntimeCleanup: false });
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1076,7 +1118,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().idleRuntimeCleanup).toBe(false);
|
||||
});
|
||||
|
||||
test("applies the global plugin switch in both directions", () => {
|
||||
test("applies the global plugin switch in both directions", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
initialPersisted: { version: 1, pluginsEnabled: false },
|
||||
});
|
||||
@@ -1084,7 +1126,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
store.onFieldChange("pluginsEnabled", (value) => changes.push(value));
|
||||
|
||||
writeConfig(paseoHome, { ...persisted, pluginsEnabled: true });
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["pluginsEnabled"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1092,7 +1134,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().pluginsEnabled).toBe(true);
|
||||
|
||||
writeConfig(paseoHome, { ...persisted, pluginsEnabled: false });
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["pluginsEnabled"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1101,7 +1143,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(changes).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test("classifies every leaf when a parent subtree is added", () => {
|
||||
test("classifies every leaf when a parent subtree is added", async () => {
|
||||
const { paseoHome, store } = createReloadableStore({
|
||||
initialPersisted: { version: 1 },
|
||||
});
|
||||
@@ -1116,14 +1158,14 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["daemon.relay.enabled"],
|
||||
restartRequiredPaths: ["daemon.relay.endpoint", "daemon.relay.useTls"],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies every leaf when the daemon subtree is removed", () => {
|
||||
test("classifies every leaf when the daemon subtree is removed", async () => {
|
||||
const { paseoHome, store } = createReloadableStore({
|
||||
initialPersisted: {
|
||||
version: 1,
|
||||
@@ -1144,7 +1186,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
});
|
||||
writeConfig(paseoHome, { version: 1 });
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["daemon.browserTools.enabled"],
|
||||
restartRequiredPaths: [
|
||||
"daemon.listen",
|
||||
@@ -1158,7 +1200,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps overridden leaves separate from restart-required siblings", () => {
|
||||
test("keeps overridden leaves separate from restart-required siblings", async () => {
|
||||
const { paseoHome, store } = createReloadableStore({
|
||||
initialPersisted: { version: 1 },
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
@@ -1170,25 +1212,25 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: ["daemon.relay.endpoint"],
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid JSON and invalid schema apply nothing", () => {
|
||||
test("invalid JSON and invalid schema apply nothing", async () => {
|
||||
const { paseoHome, store } = createReloadableStore();
|
||||
writeFileSync(path.join(paseoHome, "config.json"), "{ nope\n");
|
||||
expect(() => store.reload()).toThrow("Invalid JSON");
|
||||
await expect(store.reload()).rejects.toThrow("Invalid JSON");
|
||||
expect(store.get().browserTools.enabled).toBe(false);
|
||||
|
||||
writeConfig(paseoHome, { daemon: { browserTools: { enabled: "yes" } } });
|
||||
expect(() => store.reload()).toThrow("Invalid config");
|
||||
await expect(store.reload()).rejects.toThrow("Invalid config");
|
||||
expect(store.get().browserTools.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("removing providers and optional profiles clears live state", () => {
|
||||
test("removing providers and optional profiles clears live state", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
writeConfig(paseoHome, {
|
||||
...persisted,
|
||||
@@ -1203,10 +1245,10 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
store.reload();
|
||||
await store.reload();
|
||||
|
||||
writeConfig(paseoHome, persisted);
|
||||
const result = store.reload();
|
||||
const result = await store.reload();
|
||||
|
||||
expect(result.appliedPaths).toEqual([
|
||||
"agents.providers",
|
||||
@@ -1218,7 +1260,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().agentProfiles).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reports a launch-controlled edit without changing live state", () => {
|
||||
test("reports a launch-controlled edit without changing live state", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
@@ -1228,7 +1270,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
daemon: { ...persisted.daemon, relay: { enabled: !initialRelay } },
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
@@ -1236,7 +1278,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().relay?.enabled).toBe(initialRelay);
|
||||
});
|
||||
|
||||
test("an unrelated patch does not mark a manual override-owned edit as applied", () => {
|
||||
test("an unrelated patch does not mark a manual override-owned edit as applied", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
@@ -1244,16 +1286,16 @@ describe("DaemonConfigStore reload", () => {
|
||||
...persisted,
|
||||
daemon: { ...persisted.daemon, relay: { enabled: true } },
|
||||
});
|
||||
store.patch({ appendSystemPrompt: "patched elsewhere" });
|
||||
await store.patch({ appendSystemPrompt: "patched elsewhere" });
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
});
|
||||
|
||||
test("reports startup-only launch overrides instead of restart warnings", () => {
|
||||
test("reports startup-only launch overrides instead of restart warnings", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
|
||||
});
|
||||
@@ -1269,16 +1311,16 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a no-op reload returns empty path lists", () => {
|
||||
test("a no-op reload returns empty path lists", async () => {
|
||||
const { store } = createReloadableStore();
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1317,25 +1359,25 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
return { paseoHome, store, applied, persisted };
|
||||
}
|
||||
|
||||
test("reads, patches, and clears local STT language without losing it on other saves", () => {
|
||||
test("reads, patches, and clears local STT language without losing it on other saves", async () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
features: { dictation: { stt: { language: "fr" } } },
|
||||
});
|
||||
expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" });
|
||||
store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
await store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
|
||||
provider: "local",
|
||||
language: "fr",
|
||||
});
|
||||
const patched = store.patch({
|
||||
const patched = await store.patch({
|
||||
features: { dictation: { stt: { provider: "local", language: "de" } } },
|
||||
});
|
||||
expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" });
|
||||
store.patch({ features: { dictation: { stt: { language: null } } } });
|
||||
await store.patch({ features: { dictation: { stt: { language: null } } } });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" });
|
||||
});
|
||||
|
||||
test("reports language env overrides for both local STT sites", () => {
|
||||
test("reports language env overrides for both local STT sites", async () => {
|
||||
const { store } = createSpeechStore(
|
||||
{},
|
||||
{ PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" },
|
||||
@@ -1346,22 +1388,22 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps voice mode off until it is turned on", () => {
|
||||
test("keeps voice mode off until it is turned on", async () => {
|
||||
const { paseoHome, store } = createSpeechStore();
|
||||
|
||||
expect(store.get().features?.dictation.enabled).toBe(true);
|
||||
expect(store.get().features?.voiceMode.enabled).toBe(false);
|
||||
|
||||
const result = store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
const result = await store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
|
||||
expect(result.features?.voiceMode.enabled).toBe(true);
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("stores the OpenAI key without ever returning it", () => {
|
||||
test("stores the OpenAI key without ever returning it", async () => {
|
||||
const { paseoHome, store, applied } = createSpeechStore();
|
||||
|
||||
const result = store.patch({
|
||||
const result = await store.patch({
|
||||
features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } },
|
||||
});
|
||||
|
||||
@@ -1373,26 +1415,26 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("applies a replaced key even though the visible settings do not change", () => {
|
||||
test("applies a replaced key even though the visible settings do not change", async () => {
|
||||
const { store, applied } = createSpeechStore({
|
||||
version: 1,
|
||||
features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } },
|
||||
});
|
||||
|
||||
store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } });
|
||||
await store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } });
|
||||
|
||||
expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([
|
||||
{ provider: "openai", apiKey: "sk-new" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("switching provider clears the previous provider's model", () => {
|
||||
test("switching provider clears the previous provider's model", async () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
version: 1,
|
||||
features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } },
|
||||
});
|
||||
|
||||
const result = store.patch({ features: { dictation: { stt: { provider: "openai" } } } });
|
||||
const result = await store.patch({ features: { dictation: { stt: { provider: "openai" } } } });
|
||||
|
||||
expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
|
||||
@@ -1400,62 +1442,62 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a local model id outside the catalog without persisting it", () => {
|
||||
test("rejects a local model id outside the catalog without persisting it", async () => {
|
||||
const { paseoHome, store, applied } = createSpeechStore();
|
||||
|
||||
expect(() =>
|
||||
await expect(
|
||||
store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }),
|
||||
).toThrow("Unknown local voice model: nope");
|
||||
).rejects.toThrow("Unknown local voice model: nope");
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined();
|
||||
expect(applied).toEqual([]);
|
||||
});
|
||||
|
||||
test("reload applies hand-edited speech settings without a restart", () => {
|
||||
test("reload applies hand-edited speech settings without a restart", async () => {
|
||||
const { paseoHome, store, applied, persisted } = createSpeechStore();
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const result = store.reload();
|
||||
const result = await store.reload();
|
||||
|
||||
expect(result.appliedPaths).toContain("features.voiceMode");
|
||||
expect(result.restartRequiredPaths).toEqual([]);
|
||||
expect(store.get().features?.voiceMode.enabled).toBe(false);
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
test("merges same-provider fields and replaces the whole variant when provider changes", () => {
|
||||
test("merges same-provider fields and replaces the whole variant when provider changes", async () => {
|
||||
const { paseoHome, store } = createSpeechStore();
|
||||
store.patch({
|
||||
await store.patch({
|
||||
features: {
|
||||
voiceMode: {
|
||||
stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" },
|
||||
},
|
||||
},
|
||||
});
|
||||
store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } });
|
||||
await store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } });
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({
|
||||
provider: "openai",
|
||||
apiKey: "secret",
|
||||
model: "second",
|
||||
});
|
||||
store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } });
|
||||
await store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } });
|
||||
expect(store.get().features?.voiceMode.stt).toEqual({
|
||||
provider: "openai",
|
||||
model: "third",
|
||||
hasApiKey: true,
|
||||
});
|
||||
store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } });
|
||||
store.patch({ features: { voiceMode: { stt: { provider: "local" } } } });
|
||||
store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } });
|
||||
await store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } });
|
||||
await store.patch({ features: { voiceMode: { stt: { provider: "local" } } } });
|
||||
await store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } });
|
||||
expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false });
|
||||
});
|
||||
|
||||
test("keeps a saved OpenAI key when a full variant patch omits the write-only key", () => {
|
||||
test("keeps a saved OpenAI key when a full variant patch omits the write-only key", async () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } },
|
||||
});
|
||||
const result = store.patch({
|
||||
const result = await store.patch({
|
||||
features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } },
|
||||
});
|
||||
expect(result.features?.voiceMode.stt).toEqual({
|
||||
@@ -1471,7 +1513,7 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
expect(JSON.stringify(result)).not.toContain("saved-secret");
|
||||
});
|
||||
|
||||
test("loads a legacy host, redacts its key and writes the normalized config after any save", () => {
|
||||
test("loads a legacy host, redacts its key and writes the normalized config after any save", async () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } },
|
||||
features: { voiceMode: { enabled: true, stt: { provider: "openai" } } },
|
||||
@@ -1482,13 +1524,13 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
hasApiKey: true,
|
||||
});
|
||||
expect(JSON.stringify(store.get())).not.toContain("legacy-secret");
|
||||
store.patch({ autoArchiveAfterMerge: true });
|
||||
await store.patch({ autoArchiveAfterMerge: true });
|
||||
const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8"));
|
||||
expect(disk.providers).not.toHaveProperty("openai");
|
||||
expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret");
|
||||
});
|
||||
|
||||
test("reports env overrides and removes fallback provenance after saving a key", () => {
|
||||
test("reports env overrides and removes fallback provenance after saving a key", async () => {
|
||||
const { store } = createSpeechStore(
|
||||
{ features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } },
|
||||
{
|
||||
@@ -1514,7 +1556,7 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
"features.voiceMode.stt.model": "STT_MODEL",
|
||||
});
|
||||
expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false);
|
||||
other.patch({
|
||||
await other.patch({
|
||||
features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } },
|
||||
});
|
||||
expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
loadPersistedConfig,
|
||||
savePersistedConfig,
|
||||
savePersistedConfigAsync,
|
||||
loadPersistedConfigAsync,
|
||||
type PersistedConfig,
|
||||
} from "./persisted-config.js";
|
||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||
@@ -335,6 +336,7 @@ export function applyMutableProviderConfigToOverrides(
|
||||
}
|
||||
|
||||
export class DaemonConfigStore {
|
||||
private mutations: Promise<unknown> = Promise.resolve();
|
||||
private current: MutableDaemonConfig;
|
||||
private readonly paseoHome: string;
|
||||
private readonly logger: LoggerLike | undefined;
|
||||
@@ -376,16 +378,29 @@ export class DaemonConfigStore {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
|
||||
const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial));
|
||||
return this.applySupportedPatch(parsedPatch);
|
||||
public patch(
|
||||
partial:
|
||||
| MutableDaemonConfigPatch
|
||||
| ((current: MutableDaemonConfig) => MutableDaemonConfigPatch),
|
||||
): Promise<MutableDaemonConfig> {
|
||||
return this.enqueue(() =>
|
||||
this.applySupportedPatch(
|
||||
pickSupportedPatchFields(
|
||||
MutableDaemonConfigPatchSchema.parse(
|
||||
typeof partial === "function" ? partial(this.current) : partial,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public setAgentSkillSelection(selection: AgentSkillSelection): MutableDaemonConfig {
|
||||
return this.applySupportedPatch({ skills: { selection } });
|
||||
public setAgentSkillSelection(selection: AgentSkillSelection): Promise<MutableDaemonConfig> {
|
||||
return this.enqueue(() => this.applySupportedPatch({ skills: { selection } }));
|
||||
}
|
||||
|
||||
private applySupportedPatch(parsedPatch: SupportedMutableConfigPatch): MutableDaemonConfig {
|
||||
private async applySupportedPatch(
|
||||
parsedPatch: SupportedMutableConfigPatch,
|
||||
): Promise<MutableDaemonConfig> {
|
||||
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
|
||||
throw new Error(
|
||||
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
||||
@@ -414,7 +429,7 @@ export class DaemonConfigStore {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
const { previous: persistedBeforePatch, knownNext } = this.persistConfig(
|
||||
const { previous: persistedBeforePatch, knownNext } = await this.persistConfig(
|
||||
patch,
|
||||
removedProviders,
|
||||
);
|
||||
@@ -423,7 +438,7 @@ export class DaemonConfigStore {
|
||||
try {
|
||||
this.applyReplacement(next, { removedProviders });
|
||||
} catch (error) {
|
||||
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
|
||||
await savePersistedConfigAsync(this.paseoHome, persistedBeforePatch, this.logger);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -454,12 +469,16 @@ export class DaemonConfigStore {
|
||||
);
|
||||
}
|
||||
|
||||
public reload(): DaemonConfigReloadResult {
|
||||
public reload(): Promise<DaemonConfigReloadResult> {
|
||||
return this.enqueue(() => this.reloadPersisted());
|
||||
}
|
||||
|
||||
private async reloadPersisted(): Promise<DaemonConfigReloadResult> {
|
||||
if (!this.reloadSource) {
|
||||
throw new Error("Daemon config reload is unavailable for this daemon instance");
|
||||
}
|
||||
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger);
|
||||
const resolved = this.reloadSource.resolve(persisted);
|
||||
// Plugin source changes require the plugin lifecycle operation or a daemon
|
||||
// restart. The global switch is independently reloadable.
|
||||
@@ -621,11 +640,24 @@ export class DaemonConfigStore {
|
||||
};
|
||||
}
|
||||
|
||||
private persistConfig(
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.mutations.then(operation);
|
||||
this.mutations = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
public readServicePorts(): Promise<NonNullable<PersistedConfig["worktrees"]>["servicePorts"]> {
|
||||
return this.enqueue(
|
||||
async () =>
|
||||
(await loadPersistedConfigAsync(this.paseoHome, this.logger)).worktrees?.servicePorts,
|
||||
);
|
||||
}
|
||||
|
||||
private async persistConfig(
|
||||
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
|
||||
removeProviders: readonly string[],
|
||||
): { previous: PersistedConfig; knownNext: PersistedConfig } {
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
): Promise<{ previous: PersistedConfig; knownNext: PersistedConfig }> {
|
||||
const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger);
|
||||
const merge = (source: PersistedConfig) =>
|
||||
mergeMutablePatchIntoPersistedConfig({
|
||||
persisted: source,
|
||||
@@ -635,7 +667,7 @@ export class DaemonConfigStore {
|
||||
});
|
||||
const nextPersisted = merge(persisted);
|
||||
const knownNext = merge(this.lastKnownPersisted);
|
||||
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
|
||||
await savePersistedConfigAsync(this.paseoHome, nextPersisted, this.logger);
|
||||
return { previous: persisted, knownNext };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,53 +87,3 @@ test("logs --follow preserves the same assistant text as one-shot logs", async (
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("omitted after limit pages forward while explicit zero preserves complete CLI history", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
const initial = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
const cursor = { epoch: initial.epoch, seq: initial.window.maxSeq };
|
||||
for (let index = 0; index < 451; index++) {
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "user_message",
|
||||
text: String(index),
|
||||
});
|
||||
}
|
||||
const first = await client.fetchAgentTimeline(agent.id, { direction: "after", cursor });
|
||||
expect(first.entries).toHaveLength(200);
|
||||
expect(first.hasNewer).toBe(true);
|
||||
expect(first.endCursor).toEqual({ epoch: cursor.epoch, seq: cursor.seq + 200 });
|
||||
const second = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: first.endCursor!,
|
||||
});
|
||||
const third = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: second.endCursor!,
|
||||
});
|
||||
expect(second.entries).toHaveLength(200);
|
||||
expect(third.entries).toHaveLength(51);
|
||||
expect(third.hasNewer).toBe(false);
|
||||
expect(
|
||||
[...first.entries, ...second.entries, ...third.entries].map((entry) => entry.item),
|
||||
).toEqual(
|
||||
Array.from({ length: 451 }, (_, index) => ({ type: "user_message", text: String(index) })),
|
||||
);
|
||||
const allAfter = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor,
|
||||
limit: 0,
|
||||
});
|
||||
const allTail = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
expect(allAfter.entries).toHaveLength(451);
|
||||
expect(allAfter.hasNewer).toBe(false);
|
||||
expect(allTail.entries).toHaveLength(initial.entries.length + 451);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("daemon agent skill selection", () => {
|
||||
it("does not expose selection through the generic config patch path", async () => {
|
||||
const { config, root, store } = await createStore();
|
||||
|
||||
config.patch({
|
||||
await config.patch({
|
||||
skills: { selection: { mode: "custom", skills: ["paseo"] } },
|
||||
} as MutableDaemonConfigPatch);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export function createSkillSelectionStore(
|
||||
},
|
||||
async set(selection) {
|
||||
const parsed = coerceSkillSelection(selection);
|
||||
configStore.setAgentSkillSelection(parsed);
|
||||
await configStore.setAgentSkillSelection(parsed);
|
||||
return parsed;
|
||||
},
|
||||
async isSet() {
|
||||
|
||||
@@ -16,9 +16,11 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
loadPersistedConfig,
|
||||
loadPersistedConfigAsync,
|
||||
PersistedConfigSchema,
|
||||
readPersistedConfig,
|
||||
savePersistedConfig,
|
||||
savePersistedConfigAsync,
|
||||
} from "./persisted-config.js";
|
||||
import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js";
|
||||
|
||||
@@ -827,11 +829,14 @@ describe("readPersistedConfig with an unreadable config.json", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
|
||||
test("initializes config.json with private permissions", () => {
|
||||
describe.skipIf(process.platform === "win32").each([
|
||||
{ name: "startup", load: loadPersistedConfig, save: savePersistedConfig },
|
||||
{ name: "runtime", load: loadPersistedConfigAsync, save: savePersistedConfigAsync },
|
||||
])("persisted config file permissions ($name)", ({ load, save }) => {
|
||||
test("initializes config.json with private permissions", async () => {
|
||||
const home = createTempHome();
|
||||
try {
|
||||
loadPersistedConfig(home);
|
||||
await load(home);
|
||||
|
||||
expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
@@ -839,14 +844,14 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
|
||||
}
|
||||
});
|
||||
|
||||
test("repairs permissive config.json permissions when loading", () => {
|
||||
test("repairs permissive config.json permissions when loading", async () => {
|
||||
const home = createTempHome();
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
writeFileSync(configPath, "{}\n", { mode: PERMISSIVE_FILE_MODE });
|
||||
chmodSync(configPath, PERMISSIVE_FILE_MODE);
|
||||
|
||||
loadPersistedConfig(home);
|
||||
await load(home);
|
||||
|
||||
expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
@@ -854,11 +859,24 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
|
||||
}
|
||||
});
|
||||
|
||||
test("saves config.json with private permissions", () => {
|
||||
test("repairs unreadable owner permissions before reading config.json", async () => {
|
||||
const home = createTempHome();
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
writeFileSync(configPath, "{}\n");
|
||||
chmodSync(configPath, 0);
|
||||
expect(await load(home)).toEqual({});
|
||||
expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("saves config.json with private permissions", async () => {
|
||||
const parent = createTempHome();
|
||||
const home = path.join(parent, "home");
|
||||
try {
|
||||
savePersistedConfig(home, {
|
||||
await save(home, {
|
||||
features: { dictation: { stt: { provider: "openai", apiKey: "secret" } } },
|
||||
});
|
||||
|
||||
@@ -870,37 +888,37 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"config.json symlinked from another directory",
|
||||
() => {
|
||||
test("saving writes through the symlink and leaves it in place", () => {
|
||||
const parent = createTempHome();
|
||||
const home = path.join(parent, "home");
|
||||
const dotfiles = path.join(parent, "dotfiles");
|
||||
const linkedConfigPath = path.join(dotfiles, "config.json");
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
mkdirSync(home);
|
||||
mkdirSync(dotfiles, { mode: 0o755 });
|
||||
chmodSync(dotfiles, 0o755);
|
||||
writeFileSync(linkedConfigPath, '{ "version": 1 }\n');
|
||||
symlinkSync(linkedConfigPath, configPath);
|
||||
describe.skipIf(process.platform === "win32").each([
|
||||
{ name: "startup", save: savePersistedConfig },
|
||||
{ name: "runtime", save: savePersistedConfigAsync },
|
||||
])("config.json symlinked from another directory ($name)", ({ save }) => {
|
||||
test("saving writes through the symlink and leaves it in place", async () => {
|
||||
const parent = createTempHome();
|
||||
const home = path.join(parent, "home");
|
||||
const dotfiles = path.join(parent, "dotfiles");
|
||||
const linkedConfigPath = path.join(dotfiles, "config.json");
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
mkdirSync(home);
|
||||
mkdirSync(dotfiles, { mode: 0o755 });
|
||||
chmodSync(dotfiles, 0o755);
|
||||
writeFileSync(linkedConfigPath, '{ "version": 1 }\n');
|
||||
symlinkSync(linkedConfigPath, configPath);
|
||||
|
||||
savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } });
|
||||
await save(home, { version: 1, daemon: { relay: { enabled: false } } });
|
||||
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true);
|
||||
expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({
|
||||
version: 1,
|
||||
daemon: { relay: { enabled: false } },
|
||||
});
|
||||
expect(modeOf(dotfiles)).toBe(0o755);
|
||||
expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true);
|
||||
expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({
|
||||
version: 1,
|
||||
daemon: { relay: { enabled: false } },
|
||||
});
|
||||
expect(modeOf(dotfiles)).toBe(0o755);
|
||||
expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("speech provider config normalization", () => {
|
||||
test.each([undefined, "local"])(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -9,7 +10,12 @@ import {
|
||||
ProviderOverridesSchema,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
|
||||
import {
|
||||
ensurePrivateFile,
|
||||
ensurePrivateFileAsync,
|
||||
writePrivateFileAtomic,
|
||||
writePrivateFileAtomicSync,
|
||||
} from "./private-files.js";
|
||||
import { AgentProfileSchema, AgentSkillSelectionSchema } from "@getpaseo/protocol/agent-profile";
|
||||
import { PluginIdSchema, PluginSourceSchema } from "@getpaseo/protocol/plugin-config";
|
||||
import { TerminalProfileSchema } from "@getpaseo/protocol/terminal-profile";
|
||||
@@ -523,6 +529,40 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Runtime config IO. Startup and offline CLI consumers retain their synchronous entry points. */
|
||||
export async function loadPersistedConfigAsync(
|
||||
paseoHome: string,
|
||||
logger?: LoggerLike,
|
||||
): Promise<PersistedConfig> {
|
||||
const log = getLogger(logger);
|
||||
const configPath = getConfigPath(paseoHome);
|
||||
let raw: string;
|
||||
await ensurePrivateFileAsync(configPath);
|
||||
try {
|
||||
raw = await readFile(configPath, "utf-8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw new Error(
|
||||
`[Config] Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
try {
|
||||
raw = JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n";
|
||||
await writePrivateFileAtomic(configPath, raw);
|
||||
log?.info(`Initialized config file at ${configPath}`);
|
||||
} catch (initializationError) {
|
||||
throw new Error(
|
||||
`[Config] Failed to initialize ${configPath}: ${initializationError instanceof Error ? initializationError.message : String(initializationError)}`,
|
||||
{ cause: initializationError },
|
||||
);
|
||||
}
|
||||
}
|
||||
const config = parseConfigFile(configPath, raw, log);
|
||||
log?.info(`Loaded from ${configPath}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Observe the file without initializing a home, identity, or default configuration. */
|
||||
export function readPersistedConfig(
|
||||
paseoHome: string,
|
||||
@@ -642,16 +682,10 @@ export function savePersistedConfig(
|
||||
const log = getLogger(logger);
|
||||
const configPath = getConfigPath(paseoHome);
|
||||
|
||||
const result = PersistedConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`[Config] Invalid config to save:\n${issues}`);
|
||||
}
|
||||
const data = serializePersistedConfig(config);
|
||||
|
||||
try {
|
||||
writePrivateFileAtomicSync(configPath, JSON.stringify(result.data, null, 2) + "\n");
|
||||
writePrivateFileAtomicSync(configPath, data);
|
||||
log?.info(`Saved to ${configPath}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -660,3 +694,33 @@ export function savePersistedConfig(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function serializePersistedConfig(config: PersistedConfig): string {
|
||||
const result = PersistedConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`[Config] Invalid config to save:\n${issues}`);
|
||||
}
|
||||
|
||||
return JSON.stringify(result.data, null, 2) + "\n";
|
||||
}
|
||||
|
||||
export async function savePersistedConfigAsync(
|
||||
paseoHome: string,
|
||||
config: PersistedConfig,
|
||||
logger?: LoggerLike,
|
||||
): Promise<void> {
|
||||
const configPath = getConfigPath(paseoHome);
|
||||
const data = serializePersistedConfig(config);
|
||||
try {
|
||||
await writePrivateFileAtomic(configPath, data);
|
||||
getLogger(logger)?.info(`Saved to ${configPath}`);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[Config] Failed to write ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { isBuiltin } from "node:module";
|
||||
import path from "node:path";
|
||||
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js";
|
||||
import type { Metafile, OnResolveResult, Plugin } from "esbuild";
|
||||
import {
|
||||
isPluginClientOnlySdkSpecifier,
|
||||
isPluginServerOnlySdkSpecifier,
|
||||
PLUGIN_SDK_SPECIFIERS,
|
||||
} from "./plugin-sdk-specifiers.js";
|
||||
|
||||
import { loadEsbuild, SERVER_HOST_MODULES } from "./compiler-esbuild.js";
|
||||
|
||||
type PluginBuildTarget = "client" | "server";
|
||||
|
||||
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
|
||||
|
||||
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
|
||||
const relative = path.relative(pluginDirectory, filePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
|
||||
const segments = relative.split(path.sep);
|
||||
if (segments.includes("node_modules")) return null;
|
||||
if (segments[0] === "client") return "client";
|
||||
if (segments[0] === "server") return "server";
|
||||
if (segments[0] === "shared") return "shared";
|
||||
if (/^index\.client\.tsx?$/.test(relative)) return "client";
|
||||
if (/^index\.server\.tsx?$/.test(relative)) return "server";
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
function containsPath(directory: string, filePath: string): boolean {
|
||||
const relative = path.relative(directory, filePath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function dependencyName(specifier: string): string {
|
||||
const segments = specifier.split("/");
|
||||
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
|
||||
}
|
||||
|
||||
function findDependencyRoot(
|
||||
resolvedPath: string,
|
||||
specifier: string,
|
||||
pluginDirectory: string,
|
||||
): string | null {
|
||||
const expectedName = dependencyName(specifier);
|
||||
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
|
||||
let directory = path.dirname(resolvedPath);
|
||||
for (;;) {
|
||||
const manifestPath = path.join(directory, "package.json");
|
||||
if (existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
|
||||
if (manifest.name === expectedName || manifest.name === ambientName) {
|
||||
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
|
||||
return null;
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) return null;
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function moduleBoundaryError(
|
||||
moduleLocation: PluginModuleLocation | null,
|
||||
target: PluginBuildTarget | "shared",
|
||||
filePath: string,
|
||||
): OnResolveResult | null {
|
||||
if (moduleLocation === "invalid") {
|
||||
return {
|
||||
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
|
||||
};
|
||||
}
|
||||
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function lexicalBoundaryError(
|
||||
specifier: string,
|
||||
resolveDirectory: string,
|
||||
pluginDirectory: string,
|
||||
target: PluginBuildTarget | "shared",
|
||||
): OnResolveResult | null {
|
||||
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
|
||||
const lexicalPath = path.resolve(resolveDirectory, specifier);
|
||||
if (containsPath(pluginDirectory, lexicalPath)) {
|
||||
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
|
||||
}
|
||||
// Normalize only a containing root alias. Resolving the whole import would erase
|
||||
// an authored server/ or client/ location when the final file is a symlink.
|
||||
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
|
||||
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
|
||||
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
|
||||
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
|
||||
}
|
||||
if (path.dirname(ancestor) === ancestor) return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
|
||||
const boundaryResolution = {};
|
||||
const linkedDependencyRoots = new Set<string>();
|
||||
return {
|
||||
name: `paseo-plugin-${target}-runtime-boundary`,
|
||||
setup(buildContext) {
|
||||
const checked = new Set<string>();
|
||||
const imports = createPluginImportReader(pluginDirectory);
|
||||
function resolvedBoundaryError(
|
||||
resolvedFile: string,
|
||||
specifier: string,
|
||||
importer: string,
|
||||
owner: PluginBuildTarget | "shared",
|
||||
) {
|
||||
const file = realpathSync.native(resolvedFile);
|
||||
const location = directoryTarget(file, pluginDirectory);
|
||||
if (location === "invalid") {
|
||||
if (
|
||||
[...linkedDependencyRoots].some(
|
||||
(root) => containsPath(root, importer) && containsPath(root, file),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
|
||||
const root = findDependencyRoot(file, specifier, pluginDirectory);
|
||||
if (root) {
|
||||
linkedDependencyRoots.add(root);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return moduleBoundaryError(location, owner, file);
|
||||
}
|
||||
async function resolveImportFiles(
|
||||
file: string,
|
||||
specifier: string,
|
||||
kind: PluginImportKind,
|
||||
typeOnly: boolean,
|
||||
): Promise<Set<string>> {
|
||||
const declaration = imports.resolve(specifier, file, kind);
|
||||
const dependencyFiles = new Set<string>();
|
||||
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
|
||||
dependencyFiles.add(declaration);
|
||||
if (typeOnly && !declaration) {
|
||||
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
|
||||
}
|
||||
if (!typeOnly && kind !== "type-reference") {
|
||||
const resolution = await buildContext.resolve(specifier, {
|
||||
importer: file,
|
||||
resolveDir: path.dirname(file),
|
||||
kind,
|
||||
});
|
||||
// esbuild owns missing-runtime errors: erased imports and guarded optional
|
||||
// requires are legal. Inspect every dependency that actually resolves.
|
||||
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
|
||||
dependencyFiles.add(realpathSync.native(resolution.path));
|
||||
}
|
||||
return dependencyFiles;
|
||||
}
|
||||
async function checkSourceImports(
|
||||
sourcePath: string,
|
||||
inheritedOwner: PluginBuildTarget | "shared",
|
||||
): Promise<OnResolveResult | null> {
|
||||
const file = realpathSync.native(sourcePath);
|
||||
const owner =
|
||||
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
|
||||
const key = `${owner}:${file}`;
|
||||
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
|
||||
checked.add(key);
|
||||
for (const { specifier, kind, typeOnly } of imports.read(file)) {
|
||||
const packageSpecifier =
|
||||
kind === "type-reference"
|
||||
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
|
||||
: specifier;
|
||||
const error =
|
||||
runtimeSpecifierError(specifier, owner, file) ??
|
||||
runtimeSpecifierError(packageSpecifier, owner, file) ??
|
||||
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
|
||||
if (error) return error;
|
||||
// Protocol types are shared host contracts, also exposed by the SDK's peer
|
||||
// dependency during development. Erased imports need no plugin-local install;
|
||||
// runtime imports still go through normal dependency resolution below.
|
||||
if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue;
|
||||
// Host modules have separately enforced SDK boundaries and need no local installation.
|
||||
if (
|
||||
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
|
||||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
|
||||
isBuiltin(specifier) ||
|
||||
packageSpecifier === "@types/node"
|
||||
)
|
||||
continue;
|
||||
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
|
||||
for (const dependencyFile of dependencyFiles) {
|
||||
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
|
||||
if (boundaryError) return boundaryError;
|
||||
const dependencyError = await checkSourceImports(dependencyFile, owner);
|
||||
if (dependencyError) return dependencyError;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
|
||||
checkSourceImports(args.path, target),
|
||||
);
|
||||
buildContext.onResolve({ filter: /.*/ }, async (args) => {
|
||||
if (args.kind === "entry-point") return null;
|
||||
if (args.pluginData === boundaryResolution) return null;
|
||||
const importer =
|
||||
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
|
||||
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
|
||||
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
|
||||
const specifierError = runtimeSpecifierError(args.path, owner, importer);
|
||||
if (specifierError) return specifierError;
|
||||
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
|
||||
if (lexicalError) return lexicalError;
|
||||
const resolution = await buildContext.resolve(args.path, {
|
||||
importer,
|
||||
namespace: args.namespace,
|
||||
resolveDir,
|
||||
kind: args.kind,
|
||||
pluginData: boundaryResolution,
|
||||
with: args.with,
|
||||
});
|
||||
if (
|
||||
resolution.errors.length > 0 ||
|
||||
resolution.external ||
|
||||
resolution.namespace !== "file"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapCommonJsBundle(code: string): string {
|
||||
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
|
||||
}
|
||||
|
||||
function makeHermesInteropEager(code: string): string {
|
||||
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
|
||||
// the final loop binding, so every named import can resolve to the last export.
|
||||
// Plugin bundles execute once and do not need live bindings from host modules.
|
||||
return code.replaceAll("get: () => from[key]", "value: from[key]");
|
||||
}
|
||||
|
||||
function runtimeSpecifierError(
|
||||
specifier: string,
|
||||
target: PluginBuildTarget | "shared",
|
||||
importer: string,
|
||||
): OnResolveResult | null {
|
||||
let kind: string | null = null;
|
||||
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
|
||||
else if (
|
||||
(specifier === "@getpaseo/plugin" ||
|
||||
specifier.startsWith("@getpaseo/plugin/") ||
|
||||
specifier === "@paseo/plugin" ||
|
||||
specifier.startsWith("@paseo/plugin/")) &&
|
||||
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
|
||||
)
|
||||
kind = "Unknown SDK";
|
||||
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
|
||||
kind = "Node";
|
||||
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
|
||||
else if (
|
||||
target !== "client" &&
|
||||
(isPluginClientOnlySdkSpecifier(specifier) ||
|
||||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
|
||||
specifier,
|
||||
))
|
||||
)
|
||||
kind = "client-only";
|
||||
return kind
|
||||
? {
|
||||
errors: [
|
||||
{
|
||||
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
|
||||
function inputLocation(file: string): PluginModuleLocation | null {
|
||||
const absolutePath = path.resolve(file);
|
||||
// Metafile keys must stay unchanged for graph traversal. Only filesystem
|
||||
// inputs have canonical paths; data URLs and external specifiers do not.
|
||||
return directoryTarget(
|
||||
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
|
||||
pluginDirectory,
|
||||
);
|
||||
}
|
||||
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
|
||||
const visited = new Set<string>();
|
||||
while (pending.length) {
|
||||
const file = pending.pop()!;
|
||||
if (visited.has(file)) continue;
|
||||
visited.add(file);
|
||||
for (const dependency of inputs[file]?.imports ?? []) {
|
||||
const location = dependency.external ? null : inputLocation(dependency.path);
|
||||
const error =
|
||||
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
|
||||
(dependency.external
|
||||
? null
|
||||
: moduleBoundaryError(
|
||||
location === "invalid" ? null : location,
|
||||
"shared",
|
||||
dependency.path,
|
||||
));
|
||||
if (error?.errors?.length) throw new Error(error.errors[0].text);
|
||||
if (!dependency.external) pending.push(dependency.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
|
||||
const { build } = loadEsbuild();
|
||||
// Use native canonical paths throughout: TypeScript expands Windows short names
|
||||
// when resolving type references, while the JS realpath implementation retains them.
|
||||
const pluginDirectory = realpathSync.native(path.dirname(entryPath));
|
||||
const result = await build({
|
||||
entryPoints: [realpathSync.native(entryPath)],
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
jsx: "automatic",
|
||||
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
|
||||
loader: { ".svg": "text" },
|
||||
platform: target === "server" ? "node" : "neutral",
|
||||
target: target === "server" ? "node20" : "es2020",
|
||||
// The neutral platform reads no package.json entry fields, so packages without
|
||||
// `exports` would not resolve. Read the same fields as esbuild's browser platform
|
||||
// minus `browser`, since the bundle also runs in React Native.
|
||||
mainFields: target === "client" ? ["module", "main"] : undefined,
|
||||
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
|
||||
// so apply the same compatibility transform before the app evaluates them from source.
|
||||
supported: target === "client" ? { "async-await": false } : undefined,
|
||||
external:
|
||||
target === "client"
|
||||
? [
|
||||
...PLUGIN_SDK_SPECIFIERS,
|
||||
"@tanstack/react-query",
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react-native",
|
||||
"zod",
|
||||
]
|
||||
: SERVER_HOST_MODULES,
|
||||
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
|
||||
metafile: true,
|
||||
logLevel: "silent",
|
||||
treeShaking: true,
|
||||
write: false,
|
||||
});
|
||||
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
|
||||
const output = result.outputFiles[0]?.text;
|
||||
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
|
||||
return wrapCommonJsBundle(makeHermesInteropEager(output));
|
||||
}
|
||||
|
||||
export async function compilePlugin(entryPaths: {
|
||||
client: string | null;
|
||||
server: string | null;
|
||||
}): Promise<{
|
||||
clientBundle: string | null;
|
||||
serverBundle: string | null;
|
||||
}> {
|
||||
const [clientBundle, serverBundle] = await Promise.all([
|
||||
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
|
||||
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
|
||||
]);
|
||||
return { clientBundle, serverBundle };
|
||||
}
|
||||
|
||||
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
|
||||
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
|
||||
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
|
||||
for (const filename of filenames) {
|
||||
const filePath = path.join(directory, filename);
|
||||
const info = await stat(filePath).catch(() => null);
|
||||
if (info?.isFile()) return filePath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveEntryPaths(directory: string): Promise<{
|
||||
client: string | null;
|
||||
server: string | null;
|
||||
}> {
|
||||
const [client, server] = await Promise.all([
|
||||
findEntry(directory, CLIENT_ENTRY_FILENAMES),
|
||||
findEntry(directory, SERVER_ENTRY_FILENAMES),
|
||||
]);
|
||||
if (client || server) return { client, server };
|
||||
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
|
||||
if (legacyEntry) {
|
||||
throw new Error(
|
||||
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function compileDirectoryPlugin(
|
||||
directory: string,
|
||||
version: string,
|
||||
builtinId?: string,
|
||||
) {
|
||||
const manifest = await readPluginManifest(directory);
|
||||
if (builtinId !== undefined && manifest.id !== builtinId) {
|
||||
throw new Error(`Built-in plugin ${builtinId} has manifest ID ${manifest.id}`);
|
||||
}
|
||||
assertPluginCompatibility({ ...manifest, version, runtime: "daemon" });
|
||||
const bundles = await compilePlugin(await resolveEntryPaths(directory));
|
||||
return { ...bundles, manifest };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js";
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
|
||||
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
|
||||
|
||||
// esbuild resolves its own platform binary via require.resolve() the first time its
|
||||
// module is evaluated. Inside the packaged desktop app that resolves to a path under
|
||||
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
|
||||
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
|
||||
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
|
||||
export function unpackedEsbuildBinaryFromPackageDir(
|
||||
esbuildPackageDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
arch: string,
|
||||
): string | null {
|
||||
const asarSegment = `${path.sep}app.asar${path.sep}`;
|
||||
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
|
||||
if (asarIndex === -1) return null;
|
||||
return path.join(
|
||||
esbuildPackageDir.slice(0, asarIndex),
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
`@esbuild/${platform}-${arch}`,
|
||||
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveExistingAsarUnpackedEsbuildBinary(
|
||||
esbuildPackageDir: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
exists: (file: string) => boolean = existsSync,
|
||||
): string | null {
|
||||
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
|
||||
return binaryPath && exists(binaryPath) ? binaryPath : null;
|
||||
}
|
||||
|
||||
function resolveAsarUnpackedEsbuildBinary(): string | null {
|
||||
let esbuildDir: string;
|
||||
try {
|
||||
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
|
||||
}
|
||||
|
||||
export function loadEsbuild(): typeof import("esbuild") {
|
||||
const previousBinaryPath = process.env[ESBUILD_BINARY_PATH];
|
||||
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
|
||||
if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary;
|
||||
|
||||
try {
|
||||
// esbuild reads this variable while its CommonJS module is evaluated. Keep
|
||||
// the compatibility bridge local so it cannot become an agent's environment.
|
||||
return nodeRequire("esbuild") as typeof import("esbuild");
|
||||
} finally {
|
||||
if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH];
|
||||
else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import type { ZodIssue } from "zod";
|
||||
import { compilePlugin, compileDirectoryPlugin } from "./compiler-build.js";
|
||||
import { runPluginBuild } from "./preparation-build.js";
|
||||
import type { CompilerTask } from "./compiler.js";
|
||||
|
||||
export type CompilerReply =
|
||||
| { kind: "log"; data: Record<string, unknown>; message: string }
|
||||
| { kind: "result"; value: unknown }
|
||||
| {
|
||||
kind: "error";
|
||||
error: Error;
|
||||
properties: Record<string, unknown>;
|
||||
issues?: ZodIssue[];
|
||||
errors?: unknown[];
|
||||
warnings?: unknown[];
|
||||
};
|
||||
|
||||
const port = parentPort!;
|
||||
// The compiler owner serializes requests; the port queues the first request
|
||||
// until this entrypoint has loaded, including in source/tsx development.
|
||||
port.on("message", (task: CompilerTask) => {
|
||||
void run(task);
|
||||
});
|
||||
|
||||
async function run(task: CompilerTask): Promise<void> {
|
||||
try {
|
||||
let value: unknown;
|
||||
switch (task.kind) {
|
||||
case "entries":
|
||||
value = await compilePlugin(task.entries);
|
||||
break;
|
||||
case "directory":
|
||||
value = await compileDirectoryPlugin(task.directory, task.version, task.builtinId);
|
||||
break;
|
||||
case "prepare":
|
||||
await runPluginBuild(
|
||||
task.directory,
|
||||
task.commands,
|
||||
{
|
||||
info: (data, message) =>
|
||||
port.postMessage({ kind: "log", data, message } satisfies CompilerReply),
|
||||
},
|
||||
task.environment,
|
||||
);
|
||||
break;
|
||||
}
|
||||
port.postMessage({ kind: "result", value } satisfies CompilerReply);
|
||||
} catch (cause) {
|
||||
const error = cause instanceof Error ? cause : new Error(String(cause));
|
||||
port.postMessage({
|
||||
kind: "error",
|
||||
error,
|
||||
// Error cloning drops own metadata such as filesystem code/path. Zod's
|
||||
// readonly issues are reconstructed separately by the compiler owner.
|
||||
properties: Object.fromEntries(Object.entries(error).filter(([key]) => key !== "issues")),
|
||||
...("issues" in error ? { issues: error.issues as ZodIssue[] } : {}),
|
||||
...("errors" in error ? { errors: error.errors as unknown[] } : {}),
|
||||
...("warnings" in error ? { warnings: error.warnings as unknown[] } : {}),
|
||||
} satisfies CompilerReply);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
compilePlugin,
|
||||
compileDirectoryPlugin,
|
||||
resolveExistingAsarUnpackedEsbuildBinary,
|
||||
unpackedEsbuildBinaryFromPackageDir,
|
||||
} from "./compiler.js";
|
||||
@@ -975,3 +977,52 @@ export default function contribute() { void value; void secret; return () => und
|
||||
expect(serverBundle).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves esbuild diagnostics across the compiler boundary", async () => {
|
||||
const { server } = await createSplitPlugin();
|
||||
await writeFile(server, "export default function contribute( {");
|
||||
const error = await compilePlugin({ client: null, server }).catch((cause: unknown) => cause);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toMatchObject({
|
||||
name: "Error",
|
||||
message: expect.stringContaining("Expected identifier but found end of file"),
|
||||
errors: [
|
||||
expect.objectContaining({
|
||||
text: "Expected identifier but found end of file",
|
||||
location: expect.objectContaining({
|
||||
line: 1,
|
||||
lineText: "export default function contribute( {",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
// A failed task does not poison the next build.
|
||||
await writeFile(server, 'export default () => "recovered";');
|
||||
const bundles = await compilePlugin({ client: null, server });
|
||||
expect(bundles.serverBundle).toContain("recovered");
|
||||
});
|
||||
|
||||
it("preserves manifest validation errors across the compiler boundary", async () => {
|
||||
const { directory } = await createSplitPlugin();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: 42 }));
|
||||
const error = await compileDirectoryPlugin(directory, "0.13.2").catch((cause: unknown) => cause);
|
||||
expect(error).toBeInstanceOf(ZodError);
|
||||
expect(error).toMatchObject({
|
||||
name: "ZodError",
|
||||
issues: [expect.objectContaining({ path: ["id"], code: "invalid_type" })],
|
||||
});
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), "{");
|
||||
await expect(compileDirectoryPlugin(directory, "0.13.2")).rejects.toBeInstanceOf(SyntaxError);
|
||||
});
|
||||
|
||||
it("preserves filesystem error metadata for a missing entry", async () => {
|
||||
const { server } = await createSplitPlugin();
|
||||
await rm(server);
|
||||
await expect(compilePlugin({ client: null, server })).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
errno: expect.any(Number),
|
||||
syscall: "realpath",
|
||||
path: server,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,442 +1,151 @@
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { createRequire, isBuiltin } from "node:module";
|
||||
import path from "node:path";
|
||||
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js";
|
||||
import type { Metafile, OnResolveResult, Plugin } from "esbuild";
|
||||
import {
|
||||
isPluginClientOnlySdkSpecifier,
|
||||
isPluginServerOnlySdkSpecifier,
|
||||
PLUGIN_SDK_SPECIFIERS,
|
||||
} from "./plugin-sdk-specifiers.js";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import pLimit from "p-limit";
|
||||
import { ZodError } from "zod";
|
||||
import type * as build from "./compiler-build.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import type { CompilerReply } from "./compiler-worker.js";
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
|
||||
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
|
||||
export {
|
||||
SERVER_HOST_MODULES,
|
||||
resolveExistingAsarUnpackedEsbuildBinary,
|
||||
unpackedEsbuildBinaryFromPackageDir,
|
||||
} from "./compiler-esbuild.js";
|
||||
|
||||
// esbuild resolves its own platform binary via require.resolve() the first time its
|
||||
// module is evaluated. Inside the packaged desktop app that resolves to a path under
|
||||
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
|
||||
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
|
||||
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
|
||||
export function unpackedEsbuildBinaryFromPackageDir(
|
||||
esbuildPackageDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
arch: string,
|
||||
): string | null {
|
||||
const asarSegment = `${path.sep}app.asar${path.sep}`;
|
||||
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
|
||||
if (asarIndex === -1) return null;
|
||||
return path.join(
|
||||
esbuildPackageDir.slice(0, asarIndex),
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
`@esbuild/${platform}-${arch}`,
|
||||
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveExistingAsarUnpackedEsbuildBinary(
|
||||
esbuildPackageDir: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
exists: (file: string) => boolean = existsSync,
|
||||
): string | null {
|
||||
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
|
||||
return binaryPath && exists(binaryPath) ? binaryPath : null;
|
||||
}
|
||||
|
||||
function resolveAsarUnpackedEsbuildBinary(): string | null {
|
||||
let esbuildDir: string;
|
||||
try {
|
||||
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
|
||||
}
|
||||
|
||||
function loadEsbuild(): typeof import("esbuild") {
|
||||
const previousBinaryPath = process.env[ESBUILD_BINARY_PATH];
|
||||
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
|
||||
if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary;
|
||||
|
||||
try {
|
||||
// esbuild reads this variable while its CommonJS module is evaluated. Keep
|
||||
// the compatibility bridge local so it cannot become an agent's environment.
|
||||
return nodeRequire("esbuild") as typeof import("esbuild");
|
||||
} finally {
|
||||
if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH];
|
||||
else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath;
|
||||
}
|
||||
}
|
||||
|
||||
type PluginBuildTarget = "client" | "server";
|
||||
|
||||
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
|
||||
|
||||
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
|
||||
const relative = path.relative(pluginDirectory, filePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
|
||||
const segments = relative.split(path.sep);
|
||||
if (segments.includes("node_modules")) return null;
|
||||
if (segments[0] === "client") return "client";
|
||||
if (segments[0] === "server") return "server";
|
||||
if (segments[0] === "shared") return "shared";
|
||||
if (/^index\.client\.tsx?$/.test(relative)) return "client";
|
||||
if (/^index\.server\.tsx?$/.test(relative)) return "server";
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
function containsPath(directory: string, filePath: string): boolean {
|
||||
const relative = path.relative(directory, filePath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function dependencyName(specifier: string): string {
|
||||
const segments = specifier.split("/");
|
||||
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
|
||||
}
|
||||
|
||||
function findDependencyRoot(
|
||||
resolvedPath: string,
|
||||
specifier: string,
|
||||
pluginDirectory: string,
|
||||
): string | null {
|
||||
const expectedName = dependencyName(specifier);
|
||||
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
|
||||
let directory = path.dirname(resolvedPath);
|
||||
for (;;) {
|
||||
const manifestPath = path.join(directory, "package.json");
|
||||
if (existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
|
||||
if (manifest.name === expectedName || manifest.name === ambientName) {
|
||||
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
|
||||
return null;
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) return null;
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function moduleBoundaryError(
|
||||
moduleLocation: PluginModuleLocation | null,
|
||||
target: PluginBuildTarget | "shared",
|
||||
filePath: string,
|
||||
): OnResolveResult | null {
|
||||
if (moduleLocation === "invalid") {
|
||||
return {
|
||||
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
|
||||
export type CompilerTask =
|
||||
| { kind: "entries"; entries: Parameters<typeof build.compilePlugin>[0] }
|
||||
| { kind: "directory"; directory: string; version: string; builtinId?: string }
|
||||
| {
|
||||
kind: "prepare";
|
||||
directory: string;
|
||||
commands: PluginManifest["build"];
|
||||
environment: NodeJS.ProcessEnv;
|
||||
};
|
||||
}
|
||||
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface TaskResult {
|
||||
entries: Awaited<ReturnType<typeof build.compilePlugin>>;
|
||||
directory: Awaited<ReturnType<typeof build.compileDirectoryPlugin>>;
|
||||
prepare: void;
|
||||
}
|
||||
|
||||
function lexicalBoundaryError(
|
||||
specifier: string,
|
||||
resolveDirectory: string,
|
||||
pluginDirectory: string,
|
||||
target: PluginBuildTarget | "shared",
|
||||
): OnResolveResult | null {
|
||||
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
|
||||
const lexicalPath = path.resolve(resolveDirectory, specifier);
|
||||
if (containsPath(pluginDirectory, lexicalPath)) {
|
||||
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
|
||||
}
|
||||
// Normalize only a containing root alias. Resolving the whole import would erase
|
||||
// an authored server/ or client/ location when the final file is a symlink.
|
||||
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
|
||||
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
|
||||
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
|
||||
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
|
||||
}
|
||||
if (path.dirname(ancestor) === ancestor) return null;
|
||||
}
|
||||
}
|
||||
const IDLE_TIMEOUT_MS = 1_000;
|
||||
|
||||
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
|
||||
const boundaryResolution = {};
|
||||
const linkedDependencyRoots = new Set<string>();
|
||||
return {
|
||||
name: `paseo-plugin-${target}-runtime-boundary`,
|
||||
setup(buildContext) {
|
||||
const checked = new Set<string>();
|
||||
const imports = createPluginImportReader(pluginDirectory);
|
||||
function resolvedBoundaryError(
|
||||
resolvedFile: string,
|
||||
specifier: string,
|
||||
importer: string,
|
||||
owner: PluginBuildTarget | "shared",
|
||||
) {
|
||||
const file = realpathSync.native(resolvedFile);
|
||||
const location = directoryTarget(file, pluginDirectory);
|
||||
if (location === "invalid") {
|
||||
if (
|
||||
[...linkedDependencyRoots].some(
|
||||
(root) => containsPath(root, importer) && containsPath(root, file),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
|
||||
const root = findDependencyRoot(file, specifier, pluginDirectory);
|
||||
if (root) {
|
||||
linkedDependencyRoots.add(root);
|
||||
return null;
|
||||
}
|
||||
// Reuse the isolate during a build batch (notably daemon bootstrap), then release
|
||||
// TypeScript's heap when idle. Only this owner admits work or stops the worker.
|
||||
class PluginCompiler {
|
||||
private readonly queue = pLimit(1);
|
||||
private worker: Worker | null = null;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopping: Promise<unknown> = Promise.resolve();
|
||||
|
||||
run<T extends CompilerTask>(
|
||||
task: T,
|
||||
onLog?: (data: Record<string, unknown>, message: string) => void,
|
||||
): Promise<TaskResult[T["kind"]]> {
|
||||
return this.queue(async () => {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
await this.stopping;
|
||||
const worker = this.worker ?? this.startWorker();
|
||||
worker.ref();
|
||||
try {
|
||||
return await this.request<T>(worker, task, onLog);
|
||||
} finally {
|
||||
if (this.worker === worker) {
|
||||
worker.unref();
|
||||
this.idleTimer = setTimeout(() => this.stopWorker(worker), IDLE_TIMEOUT_MS);
|
||||
this.idleTimer.unref();
|
||||
} else {
|
||||
await this.stopping;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startWorker(): Worker {
|
||||
const source = import.meta.url.endsWith(".ts");
|
||||
const url = new URL(source ? "./compiler-worker.ts" : "./compiler-worker.js", import.meta.url);
|
||||
// Node workers do not inherit tsx's in-process loader registration. Register
|
||||
// it inside development/test isolates; dist workers execute ordinary JS.
|
||||
const worker = source
|
||||
? new Worker(
|
||||
`import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(url.href)}); });`,
|
||||
{ eval: true, execArgv: [] },
|
||||
)
|
||||
: new Worker(url, { execArgv: [] });
|
||||
this.worker = worker;
|
||||
// Keep error/exit ownership while idle too. The next caller gets a fresh
|
||||
// isolate after a crash; author build commands are never retried.
|
||||
worker.on("error", () => this.stopWorker(worker));
|
||||
worker.on("exit", () => {
|
||||
if (this.worker === worker) this.stopWorker(worker);
|
||||
});
|
||||
return worker;
|
||||
}
|
||||
|
||||
private stopWorker(worker: Worker): void {
|
||||
if (this.worker !== worker) return;
|
||||
this.worker = null;
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
this.stopping = worker.terminate();
|
||||
}
|
||||
|
||||
private async request<T extends CompilerTask>(
|
||||
worker: Worker,
|
||||
task: T,
|
||||
onLog?: (data: Record<string, unknown>, message: string) => void,
|
||||
): Promise<TaskResult[T["kind"]]> {
|
||||
let onMessage: (reply: CompilerReply) => void;
|
||||
let onError: (error: Error) => void;
|
||||
let onExit: (code: number) => void;
|
||||
try {
|
||||
return await new Promise<TaskResult[T["kind"]]>((resolve, reject) => {
|
||||
onError = reject;
|
||||
onExit = (code) => reject(new Error(`Plugin compiler worker exited (code ${code})`));
|
||||
onMessage = (reply) => {
|
||||
if (reply.kind === "log") {
|
||||
onLog?.(reply.data, reply.message);
|
||||
} else if (reply.kind === "result") {
|
||||
resolve(reply.value as TaskResult[T["kind"]]);
|
||||
} else {
|
||||
const error = reply.issues ? new ZodError(reply.issues) : reply.error;
|
||||
Object.assign(error, reply.properties);
|
||||
if (reply.errors) Object.assign(error, { errors: reply.errors });
|
||||
if (reply.warnings) Object.assign(error, { warnings: reply.warnings });
|
||||
// Promise.all can reject while the other target is still compiling.
|
||||
// Retire the isolate before another request can enter it.
|
||||
this.stopWorker(worker);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
return moduleBoundaryError(location, owner, file);
|
||||
}
|
||||
async function resolveImportFiles(
|
||||
file: string,
|
||||
specifier: string,
|
||||
kind: PluginImportKind,
|
||||
typeOnly: boolean,
|
||||
): Promise<Set<string>> {
|
||||
const declaration = imports.resolve(specifier, file, kind);
|
||||
const dependencyFiles = new Set<string>();
|
||||
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
|
||||
dependencyFiles.add(declaration);
|
||||
if (typeOnly && !declaration) {
|
||||
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
|
||||
}
|
||||
if (!typeOnly && kind !== "type-reference") {
|
||||
const resolution = await buildContext.resolve(specifier, {
|
||||
importer: file,
|
||||
resolveDir: path.dirname(file),
|
||||
kind,
|
||||
});
|
||||
// esbuild owns missing-runtime errors: erased imports and guarded optional
|
||||
// requires are legal. Inspect every dependency that actually resolves.
|
||||
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
|
||||
dependencyFiles.add(realpathSync.native(resolution.path));
|
||||
}
|
||||
return dependencyFiles;
|
||||
}
|
||||
async function checkSourceImports(
|
||||
sourcePath: string,
|
||||
inheritedOwner: PluginBuildTarget | "shared",
|
||||
): Promise<OnResolveResult | null> {
|
||||
const file = realpathSync.native(sourcePath);
|
||||
const owner =
|
||||
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
|
||||
const key = `${owner}:${file}`;
|
||||
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
|
||||
checked.add(key);
|
||||
for (const { specifier, kind, typeOnly } of imports.read(file)) {
|
||||
const packageSpecifier =
|
||||
kind === "type-reference"
|
||||
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
|
||||
: specifier;
|
||||
const error =
|
||||
runtimeSpecifierError(specifier, owner, file) ??
|
||||
runtimeSpecifierError(packageSpecifier, owner, file) ??
|
||||
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
|
||||
if (error) return error;
|
||||
// Protocol types are shared host contracts, also exposed by the SDK's peer
|
||||
// dependency during development. Erased imports need no plugin-local install;
|
||||
// runtime imports still go through normal dependency resolution below.
|
||||
if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue;
|
||||
// Host modules have separately enforced SDK boundaries and need no local installation.
|
||||
if (
|
||||
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
|
||||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
|
||||
isBuiltin(specifier) ||
|
||||
packageSpecifier === "@types/node"
|
||||
)
|
||||
continue;
|
||||
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
|
||||
for (const dependencyFile of dependencyFiles) {
|
||||
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
|
||||
if (boundaryError) return boundaryError;
|
||||
const dependencyError = await checkSourceImports(dependencyFile, owner);
|
||||
if (dependencyError) return dependencyError;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
|
||||
checkSourceImports(args.path, target),
|
||||
);
|
||||
buildContext.onResolve({ filter: /.*/ }, async (args) => {
|
||||
if (args.kind === "entry-point") return null;
|
||||
if (args.pluginData === boundaryResolution) return null;
|
||||
const importer =
|
||||
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
|
||||
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
|
||||
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
|
||||
const specifierError = runtimeSpecifierError(args.path, owner, importer);
|
||||
if (specifierError) return specifierError;
|
||||
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
|
||||
if (lexicalError) return lexicalError;
|
||||
const resolution = await buildContext.resolve(args.path, {
|
||||
importer,
|
||||
namespace: args.namespace,
|
||||
resolveDir,
|
||||
kind: args.kind,
|
||||
pluginData: boundaryResolution,
|
||||
with: args.with,
|
||||
});
|
||||
if (
|
||||
resolution.errors.length > 0 ||
|
||||
resolution.external ||
|
||||
resolution.namespace !== "file"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
|
||||
};
|
||||
worker.on("error", onError);
|
||||
worker.on("exit", onExit);
|
||||
worker.on("message", onMessage);
|
||||
worker.postMessage(task, []);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapCommonJsBundle(code: string): string {
|
||||
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
|
||||
}
|
||||
|
||||
function makeHermesInteropEager(code: string): string {
|
||||
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
|
||||
// the final loop binding, so every named import can resolve to the last export.
|
||||
// Plugin bundles execute once and do not need live bindings from host modules.
|
||||
return code.replaceAll("get: () => from[key]", "value: from[key]");
|
||||
}
|
||||
|
||||
function runtimeSpecifierError(
|
||||
specifier: string,
|
||||
target: PluginBuildTarget | "shared",
|
||||
importer: string,
|
||||
): OnResolveResult | null {
|
||||
let kind: string | null = null;
|
||||
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
|
||||
else if (
|
||||
(specifier === "@getpaseo/plugin" ||
|
||||
specifier.startsWith("@getpaseo/plugin/") ||
|
||||
specifier === "@paseo/plugin" ||
|
||||
specifier.startsWith("@paseo/plugin/")) &&
|
||||
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
|
||||
)
|
||||
kind = "Unknown SDK";
|
||||
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
|
||||
kind = "Node";
|
||||
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
|
||||
else if (
|
||||
target !== "client" &&
|
||||
(isPluginClientOnlySdkSpecifier(specifier) ||
|
||||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
|
||||
specifier,
|
||||
))
|
||||
)
|
||||
kind = "client-only";
|
||||
return kind
|
||||
? {
|
||||
errors: [
|
||||
{
|
||||
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
|
||||
function inputLocation(file: string): PluginModuleLocation | null {
|
||||
const absolutePath = path.resolve(file);
|
||||
// Metafile keys must stay unchanged for graph traversal. Only filesystem
|
||||
// inputs have canonical paths; data URLs and external specifiers do not.
|
||||
return directoryTarget(
|
||||
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
|
||||
pluginDirectory,
|
||||
);
|
||||
}
|
||||
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
|
||||
const visited = new Set<string>();
|
||||
while (pending.length) {
|
||||
const file = pending.pop()!;
|
||||
if (visited.has(file)) continue;
|
||||
visited.add(file);
|
||||
for (const dependency of inputs[file]?.imports ?? []) {
|
||||
const location = dependency.external ? null : inputLocation(dependency.path);
|
||||
const error =
|
||||
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
|
||||
(dependency.external
|
||||
? null
|
||||
: moduleBoundaryError(
|
||||
location === "invalid" ? null : location,
|
||||
"shared",
|
||||
dependency.path,
|
||||
));
|
||||
if (error?.errors?.length) throw new Error(error.errors[0].text);
|
||||
if (!dependency.external) pending.push(dependency.path);
|
||||
} finally {
|
||||
worker.off("message", onMessage!);
|
||||
worker.off("error", onError!);
|
||||
worker.off("exit", onExit!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
|
||||
const { build } = loadEsbuild();
|
||||
// Use native canonical paths throughout: TypeScript expands Windows short names
|
||||
// when resolving type references, while the JS realpath implementation retains them.
|
||||
const pluginDirectory = realpathSync.native(path.dirname(entryPath));
|
||||
const result = await build({
|
||||
entryPoints: [realpathSync.native(entryPath)],
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
jsx: "automatic",
|
||||
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
|
||||
loader: { ".svg": "text" },
|
||||
platform: target === "server" ? "node" : "neutral",
|
||||
target: target === "server" ? "node20" : "es2020",
|
||||
// The neutral platform reads no package.json entry fields, so packages without
|
||||
// `exports` would not resolve. Read the same fields as esbuild's browser platform
|
||||
// minus `browser`, since the bundle also runs in React Native.
|
||||
mainFields: target === "client" ? ["module", "main"] : undefined,
|
||||
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
|
||||
// so apply the same compatibility transform before the app evaluates them from source.
|
||||
supported: target === "client" ? { "async-await": false } : undefined,
|
||||
external:
|
||||
target === "client"
|
||||
? [
|
||||
...PLUGIN_SDK_SPECIFIERS,
|
||||
"@tanstack/react-query",
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react-native",
|
||||
"zod",
|
||||
]
|
||||
: SERVER_HOST_MODULES,
|
||||
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
|
||||
metafile: true,
|
||||
logLevel: "silent",
|
||||
treeShaking: true,
|
||||
write: false,
|
||||
});
|
||||
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
|
||||
const output = result.outputFiles[0]?.text;
|
||||
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
|
||||
return wrapCommonJsBundle(makeHermesInteropEager(output));
|
||||
const compiler = new PluginCompiler();
|
||||
|
||||
export function runCompilerTask<T extends CompilerTask>(
|
||||
task: T,
|
||||
onLog?: (data: Record<string, unknown>, message: string) => void,
|
||||
): Promise<TaskResult[T["kind"]]> {
|
||||
return compiler.run(task, onLog);
|
||||
}
|
||||
|
||||
export async function compilePlugin(entryPaths: {
|
||||
client: string | null;
|
||||
server: string | null;
|
||||
}): Promise<{
|
||||
clientBundle: string | null;
|
||||
serverBundle: string | null;
|
||||
}> {
|
||||
const [clientBundle, serverBundle] = await Promise.all([
|
||||
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
|
||||
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
|
||||
]);
|
||||
return { clientBundle, serverBundle };
|
||||
export function compilePlugin(entries: Parameters<typeof build.compilePlugin>[0]) {
|
||||
return runCompilerTask({ kind: "entries", entries });
|
||||
}
|
||||
|
||||
export function compileDirectoryPlugin(directory: string, version: string, builtinId?: string) {
|
||||
return runCompilerTask({ kind: "directory", directory, version, builtinId });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
rename,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open as openFile,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
@@ -526,7 +527,7 @@ describe("PluginService", () => {
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const url = `http://127.0.0.1:${address.port}`;
|
||||
const store = createStore(home);
|
||||
store.patch({ pluginsEnabled: false });
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
const service = bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
|
||||
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
|
||||
@@ -1079,13 +1080,13 @@ export default function contribute(plugin: unknown) {
|
||||
await service.installDirectory({ path: first });
|
||||
await service.installDirectory({ path: second });
|
||||
|
||||
store.patch({ pluginsEnabled: false });
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
|
||||
expect(service.catalog()).toEqual([]);
|
||||
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
|
||||
"Plugin is not available",
|
||||
);
|
||||
store.patch({ pluginsEnabled: true });
|
||||
await store.patch({ pluginsEnabled: true });
|
||||
await service.reloadPlugin("first");
|
||||
expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([
|
||||
{ id: "first", status: "running" },
|
||||
@@ -1100,16 +1101,16 @@ export default function contribute(plugin: unknown) {
|
||||
const store = createStore(home, {
|
||||
slow: { source: "directory", path: "/plugins/slow", enabled: true },
|
||||
});
|
||||
store.patch({ pluginsEnabled: false });
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
const paused = createPausedRuntime();
|
||||
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
|
||||
runtime: paused.runtime,
|
||||
});
|
||||
await service.start();
|
||||
|
||||
store.patch({ pluginsEnabled: true });
|
||||
await store.patch({ pluginsEnabled: true });
|
||||
await paused.started;
|
||||
store.patch({ pluginsEnabled: false });
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
paused.releaseStart();
|
||||
await service.stopAllPlugins();
|
||||
|
||||
@@ -1119,6 +1120,47 @@ export default function contribute(plugin: unknown) {
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists overlapping enable and disable without losing either source mutation", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const store = createStore(home, {
|
||||
a: { source: "directory", path: "/plugins/a", enabled: false },
|
||||
b: { source: "directory", path: "/plugins/b", enabled: true },
|
||||
});
|
||||
const paused = createPluginSelectivePausedRuntime("unused");
|
||||
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
|
||||
runtime: paused.runtime,
|
||||
});
|
||||
await service.start();
|
||||
await Promise.all([service.enablePlugin("a"), service.disablePlugin("b")]);
|
||||
expect(store.get().plugins).toEqual({
|
||||
a: { source: "directory", path: "/plugins/a", enabled: true },
|
||||
b: { source: "directory", path: "/plugins/b", enabled: false },
|
||||
});
|
||||
await service.stopAllPlugins();
|
||||
});
|
||||
|
||||
it("clears a failed disable intent so the previous enabled state can publish", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const store = createStore(home, {
|
||||
slow: { source: "directory", path: "/plugins/slow", enabled: false },
|
||||
});
|
||||
const paused = createPausedRuntime();
|
||||
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
|
||||
runtime: paused.runtime,
|
||||
});
|
||||
await service.start();
|
||||
const enabling = service.enablePlugin("slow");
|
||||
await paused.started;
|
||||
await writeFile(path.join(home, "config.json"), "invalid JSON");
|
||||
await expect(service.disablePlugin("slow")).rejects.toThrow("Invalid JSON");
|
||||
paused.releaseStart();
|
||||
await expect(enabling).resolves.toMatchObject({ enabled: true, status: "running" });
|
||||
expect(service.catalog()).toEqual([{ id: "slow", clientBundle: "bundle" }]);
|
||||
await service.stopAllPlugins();
|
||||
});
|
||||
|
||||
it("does not publish an in-flight enable after a later plugin disable", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
@@ -1557,3 +1599,60 @@ async function applyReviewedUpdate(service: PluginService, pluginId: string) {
|
||||
expect(preview?.outcome).toBe("update");
|
||||
return service.applyUpdates([preview!.proposal!]);
|
||||
}
|
||||
|
||||
it("keeps reload and enable responsive while compiling a real plugin", async () => {
|
||||
const directory = await createPlugin("responsive-build", "export default () => {};");
|
||||
// Syntax-dense input exercises TypeScript's AST walk, not just esbuild's subprocess.
|
||||
await rm(path.join(directory, "index.server.ts"));
|
||||
const entry = await openFile(path.join(directory, "index.client.ts"), "w");
|
||||
try {
|
||||
await entry.write("const values = [");
|
||||
for (let offset = 0; offset < 350_000; offset += 10_000) {
|
||||
await entry.write(
|
||||
Array.from({ length: 10_000 }, (_, index) => offset + index).join(",") + ",",
|
||||
);
|
||||
}
|
||||
await entry.write("]; export default function contribute() { return () => values; }");
|
||||
} finally {
|
||||
await entry.close();
|
||||
}
|
||||
const service = createService(directory, {
|
||||
"responsive-build": { source: "directory", path: directory, enabled: false },
|
||||
});
|
||||
await service.start();
|
||||
try {
|
||||
for (const action of [
|
||||
() => service.enablePlugin("responsive-build"),
|
||||
() => service.reloadPlugin("responsive-build"),
|
||||
]) {
|
||||
let previous = performance.now();
|
||||
let longest = 0;
|
||||
const sample = () => {
|
||||
const now = performance.now();
|
||||
longest = Math.max(longest, now - previous);
|
||||
previous = now;
|
||||
};
|
||||
const timer = setInterval(sample, 1);
|
||||
try {
|
||||
await action();
|
||||
sample();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
}
|
||||
process.stderr.write(`plugin compile longest loop gap: ${longest.toFixed(2)} ms\n`);
|
||||
expect(service.catalog()[0]?.clientBundle).toContain("349999");
|
||||
expect(longest).toBeLessThan(20);
|
||||
}
|
||||
// Evaluate after both probes so parsing the returned code is not charged to compilation.
|
||||
const factory = globalThis.eval(service.catalog()[0]!.clientBundle) as (
|
||||
require: (specifier: string) => never,
|
||||
) => { default: () => () => number[] };
|
||||
const values = factory((specifier) => {
|
||||
throw new Error(`Unexpected fixture dependency: ${specifier}`);
|
||||
}).default()();
|
||||
expect(values).toHaveLength(350_000);
|
||||
expect(values.every((value, index) => value === index)).toBe(true);
|
||||
} finally {
|
||||
await service.stopAllPlugins();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -92,6 +92,10 @@ export class PluginService {
|
||||
private readonly providerListeners = new Set<() => void>();
|
||||
private lifecycle = Promise.resolve();
|
||||
private globalStartsBlocked = true;
|
||||
private readonly pendingOperations = new Map<
|
||||
string,
|
||||
{ kind: "disable" | "remove"; completion: Promise<void> }
|
||||
>();
|
||||
private started = false;
|
||||
private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>();
|
||||
|
||||
@@ -265,11 +269,7 @@ export class PluginService {
|
||||
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
|
||||
);
|
||||
}
|
||||
const sources = {
|
||||
...this.configStore.get().plugins,
|
||||
[pluginId]: { source: "directory" as const, path: directory, enabled: true },
|
||||
};
|
||||
this.configStore.patch({ plugins: sources });
|
||||
await this.patchSource(pluginId, { source: "directory", path: directory, enabled: true });
|
||||
if (this.canPublish(pluginId)) await this.startConfigured(pluginId);
|
||||
this.notify(pluginId);
|
||||
const installed = await this.requireItem(pluginId);
|
||||
@@ -328,15 +328,17 @@ export class PluginService {
|
||||
await managedSources.discard(candidate);
|
||||
throw error;
|
||||
}
|
||||
this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true });
|
||||
await this.patchSource(pluginId, {
|
||||
source: "directory",
|
||||
path: candidate.directory,
|
||||
enabled: true,
|
||||
});
|
||||
try {
|
||||
if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory);
|
||||
managedSources.commit(pluginId, candidate.record);
|
||||
} catch (error) {
|
||||
await this.stopPlugin(pluginId);
|
||||
const sources = { ...this.configStore.get().plugins };
|
||||
delete sources[pluginId];
|
||||
this.configStore.patch({ plugins: sources });
|
||||
await this.removeSource(pluginId);
|
||||
await managedSources.discard(candidate);
|
||||
this.errors.delete(pluginId);
|
||||
this.notify(pluginId);
|
||||
@@ -428,10 +430,11 @@ export class PluginService {
|
||||
}
|
||||
|
||||
async enablePlugin(pluginId: string): Promise<PluginListItem> {
|
||||
const source = this.requireSource(pluginId);
|
||||
this.patchSource(pluginId, { ...source, enabled: true });
|
||||
await this.patchSource(pluginId, (source) => ({ ...source, enabled: true }));
|
||||
this.errors.delete(pluginId);
|
||||
return this.enqueue(async () => {
|
||||
// Cancellation persistence uses the store queue, so it never waits behind a blocked start.
|
||||
await this.pendingOperations.get(pluginId)?.completion.catch(() => {});
|
||||
const current = this.requireSource(pluginId);
|
||||
if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) {
|
||||
await this.startExplicit(pluginId, current.path);
|
||||
@@ -442,8 +445,17 @@ export class PluginService {
|
||||
}
|
||||
|
||||
async disablePlugin(pluginId: string): Promise<PluginListItem> {
|
||||
const source = this.requireSource(pluginId);
|
||||
this.patchSource(pluginId, { ...source, enabled: false });
|
||||
const operation = {
|
||||
kind: "disable",
|
||||
completion: this.patchSource(pluginId, (source) => ({ ...source, enabled: false })),
|
||||
} as const;
|
||||
this.pendingOperations.set(pluginId, operation);
|
||||
try {
|
||||
await operation.completion;
|
||||
} finally {
|
||||
if (this.pendingOperations.get(pluginId) === operation)
|
||||
this.pendingOperations.delete(pluginId);
|
||||
}
|
||||
const stopping = this.stopPlugin(pluginId);
|
||||
return this.enqueue(async () => {
|
||||
await stopping;
|
||||
@@ -455,10 +467,15 @@ export class PluginService {
|
||||
|
||||
async removePlugin(pluginId: string): Promise<void> {
|
||||
this.requireSource(pluginId);
|
||||
const operation = { kind: "remove", completion: this.removeSource(pluginId) } as const;
|
||||
this.pendingOperations.set(pluginId, operation);
|
||||
try {
|
||||
await operation.completion;
|
||||
} finally {
|
||||
if (this.pendingOperations.get(pluginId) === operation)
|
||||
this.pendingOperations.delete(pluginId);
|
||||
}
|
||||
const stopping = this.stopPlugin(pluginId);
|
||||
const sources = { ...this.configStore.get().plugins };
|
||||
delete sources[pluginId];
|
||||
this.configStore.patch({ plugins: sources });
|
||||
await this.enqueue(async () => {
|
||||
await stopping;
|
||||
this.runtime.clearLogs(pluginId);
|
||||
@@ -531,6 +548,7 @@ export class PluginService {
|
||||
private canPublish(pluginId: string): boolean {
|
||||
const config = this.configStore.get();
|
||||
return (
|
||||
!this.pendingOperations.has(pluginId) &&
|
||||
!this.globalStartsBlocked &&
|
||||
config.pluginsEnabled === true &&
|
||||
config.plugins?.[pluginId]?.enabled !== false &&
|
||||
@@ -744,7 +762,10 @@ export class PluginService {
|
||||
await managedSources.discard(candidate);
|
||||
throw new Error(`Plugin is no longer configured: ${pluginId}`);
|
||||
}
|
||||
this.patchSource(pluginId, { ...activatedSource, path: candidate.directory });
|
||||
await this.patchSource(pluginId, (currentSource) => ({
|
||||
...currentSource,
|
||||
path: candidate.directory,
|
||||
}));
|
||||
this.errors.delete(pluginId);
|
||||
this.notify(pluginId);
|
||||
let warning: string | undefined;
|
||||
@@ -786,9 +807,28 @@ export class PluginService {
|
||||
return source;
|
||||
}
|
||||
|
||||
private patchSource(pluginId: string, source: PluginSource): void {
|
||||
this.configStore.patch({
|
||||
plugins: { ...this.configStore.get().plugins, [pluginId]: source },
|
||||
private async patchSource(
|
||||
pluginId: string,
|
||||
update: PluginSource | ((source: PluginSource) => PluginSource),
|
||||
): Promise<void> {
|
||||
await this.configStore.patch((current) => {
|
||||
const source = current.plugins?.[pluginId];
|
||||
if (typeof update === "function" && !source)
|
||||
throw new Error(`Plugin is not configured: ${pluginId}`);
|
||||
return {
|
||||
plugins: {
|
||||
...current.plugins,
|
||||
[pluginId]: typeof update === "function" ? update(source!) : update,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async removeSource(pluginId: string): Promise<void> {
|
||||
await this.configStore.patch((current) => {
|
||||
const sources = { ...current.plugins };
|
||||
delete sources[pluginId];
|
||||
return { plugins: sources };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import pino from "pino";
|
||||
import { createPaseoDaemon } from "../bootstrap.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { createRequire } from "node:module";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -198,6 +201,60 @@ export default function contribute(server: PluginServerContext) {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("password-protected config reload preserves startup auth and session construction does not read config", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-protected-config-reload-"));
|
||||
roots.push(home);
|
||||
const password = "synthetic-audit-password";
|
||||
const config = loadConfig(home, { env: { PASEO_PASSWORD: password, PASEO_RELAY_ENABLED: "0" } });
|
||||
const daemon = await createPaseoDaemon(
|
||||
{
|
||||
...config,
|
||||
listen: "127.0.0.1:0",
|
||||
mcpEnabled: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
staticDir: home,
|
||||
},
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
await daemon.start();
|
||||
const target = daemon.getListenTarget();
|
||||
if (target?.type !== "tcp") throw new Error("Expected TCP daemon");
|
||||
const connect = () =>
|
||||
new DaemonClient({ url: `ws://127.0.0.1:${target.port}/ws`, password, appVersion: "0.4.0" });
|
||||
const client = connect();
|
||||
const replacement = connect();
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const configPath = path.join(home, "config.json");
|
||||
await writeFile(configPath, JSON.stringify({ daemon: { browserTools: { enabled: true } } }));
|
||||
let longestGap = 0;
|
||||
let previous = performance.now();
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - previous);
|
||||
previous = now;
|
||||
}, 1);
|
||||
try {
|
||||
const reloaded = await client.reloadDaemonConfig();
|
||||
longestGap = Math.max(longestGap, performance.now() - previous);
|
||||
expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled");
|
||||
expect(longestGap).toBeLessThan(50);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
expect((await client.getDaemonConfig()).config.browserTools.enabled).toBe(true);
|
||||
// A new physical session uses ready daemon state; corrupt config is reported only by an explicit read.
|
||||
await writeFile(configPath, "invalid JSON");
|
||||
await replacement.connect();
|
||||
await replacement.fetchAgents({ subscribe: {} });
|
||||
await expect(replacement.reloadDaemonConfig()).rejects.toThrow("Invalid JSON");
|
||||
} finally {
|
||||
await Promise.all([client.close(), replacement.close()]);
|
||||
await daemon.stop();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("daemon config reload enables and disables configured plugins without restarting", async () => {
|
||||
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-reload-plugin-"));
|
||||
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-reload-home-"));
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { spawnProcess } from "../../utils/spawn.js";
|
||||
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
const OUTPUT_LIMIT = 64 * 1024;
|
||||
|
||||
export async function runPluginBuild(
|
||||
directory: string,
|
||||
commands: PluginManifest["build"],
|
||||
logger: { info(data: Record<string, unknown>, message: string): void },
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
for (const command of commands ?? []) {
|
||||
logger.info({ directory, command }, "Running plugin build command");
|
||||
const result = await run(command, directory, environment);
|
||||
if (result.stdout)
|
||||
logger.info({ directory, command, output: result.stdout }, "Plugin build stdout");
|
||||
if (result.stderr)
|
||||
logger.info({ directory, command, output: result.stderr }, "Plugin build stderr");
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`,
|
||||
);
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function run(
|
||||
command: string[],
|
||||
directory: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const executable = command[0]!;
|
||||
const arguments_ = command.slice(1);
|
||||
const child = spawnProcess(executable, arguments_, { cwd: directory, env: environment });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let error: Error | undefined;
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout = appendOutput(stdout, chunk.toString());
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr = appendOutput(stderr, chunk.toString());
|
||||
});
|
||||
child.once("error", (spawnError) => {
|
||||
error = spawnError;
|
||||
});
|
||||
child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error }));
|
||||
});
|
||||
}
|
||||
|
||||
function appendOutput(current: string, next: string): string {
|
||||
return `${current}${next}`.slice(-OUTPUT_LIMIT);
|
||||
}
|
||||
|
||||
function formatCommand(command: string[]): string {
|
||||
return command.map((argument) => JSON.stringify(argument)).join(" ");
|
||||
}
|
||||
|
||||
function formatOutput(result: CommandResult): string {
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
||||
return output ? `\n${output}` : "";
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: Error;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { runPluginBuild } from "./preparation.js";
|
||||
@@ -117,6 +119,52 @@ describe("runPluginBuild", () => {
|
||||
).rejects.toThrow(/Plugin build command failed \(exit 2\)/);
|
||||
});
|
||||
|
||||
it("forwards build progress and output from the worker to the caller's logger", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
|
||||
directories.push(directory);
|
||||
const records: Array<{ msg: string; output?: string }> = [];
|
||||
const logger = pino(
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
records.push(JSON.parse(chunk.toString()));
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
);
|
||||
await runPluginBuild(
|
||||
directory,
|
||||
[[process.execPath, "-e", 'console.log("prepared"); console.error("diagnostic");']],
|
||||
logger,
|
||||
);
|
||||
expect(records).toMatchObject([
|
||||
{ msg: "Running plugin build command" },
|
||||
{ msg: "Plugin build stdout", output: "prepared\n" },
|
||||
{ msg: "Plugin build stderr", output: expect.stringContaining("diagnostic\n") },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the caller's current environment across consecutive builds", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
|
||||
directories.push(directory);
|
||||
const previous = process.env.PASEO_PLUGIN_TEST_BUILD_VALUE;
|
||||
const command = [
|
||||
process.execPath,
|
||||
"-e",
|
||||
'require("node:fs").writeFileSync(process.argv[1], process.env.PASEO_PLUGIN_TEST_BUILD_VALUE);',
|
||||
];
|
||||
try {
|
||||
process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "first";
|
||||
await runPluginBuild(directory, [[...command, "first.txt"]], createTestLogger());
|
||||
process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "second";
|
||||
await runPluginBuild(directory, [[...command, "second.txt"]], createTestLogger());
|
||||
expect(await readFile(path.join(directory, "first.txt"), "utf8")).toBe("first");
|
||||
expect(await readFile(path.join(directory, "second.txt"), "utf8")).toBe("second");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.PASEO_PLUGIN_TEST_BUILD_VALUE;
|
||||
else process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("handles empty or undefined build commands gracefully", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
|
||||
directories.push(directory);
|
||||
|
||||
@@ -1,71 +1,15 @@
|
||||
import { spawnProcess } from "../../utils/spawn.js";
|
||||
import type pino from "pino";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
const OUTPUT_LIMIT = 64 * 1024;
|
||||
import { runCompilerTask } from "./compiler.js";
|
||||
|
||||
export async function runPluginBuild(
|
||||
directory: string,
|
||||
commands: PluginManifest["build"],
|
||||
logger: pino.Logger,
|
||||
): Promise<void> {
|
||||
for (const command of commands ?? []) {
|
||||
logger.info({ directory, command }, "Running plugin build command");
|
||||
const result = await run(command, directory);
|
||||
if (result.stdout)
|
||||
logger.info({ directory, command, output: result.stdout }, "Plugin build stdout");
|
||||
if (result.stderr)
|
||||
logger.info({ directory, command, output: result.stderr }, "Plugin build stderr");
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`,
|
||||
);
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function run(command: string[], directory: string): Promise<CommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const executable = command[0]!;
|
||||
const arguments_ = command.slice(1);
|
||||
const child = spawnProcess(executable, arguments_, { cwd: directory });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let error: Error | undefined;
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout = appendOutput(stdout, chunk.toString());
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr = appendOutput(stderr, chunk.toString());
|
||||
});
|
||||
child.once("error", (spawnError) => {
|
||||
error = spawnError;
|
||||
});
|
||||
child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error }));
|
||||
});
|
||||
}
|
||||
|
||||
function appendOutput(current: string, next: string): string {
|
||||
return `${current}${next}`.slice(-OUTPUT_LIMIT);
|
||||
}
|
||||
|
||||
function formatCommand(command: string[]): string {
|
||||
return command.map((argument) => JSON.stringify(argument)).join(" ");
|
||||
}
|
||||
|
||||
function formatOutput(result: CommandResult): string {
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
||||
return output ? `\n${output}` : "";
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: Error;
|
||||
if (!commands?.length) return;
|
||||
await runCompilerTask(
|
||||
{ kind: "prepare", directory, commands, environment: { ...process.env } },
|
||||
(data, message) => logger.info(data, message),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { fork } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PassThrough } from "node:stream";
|
||||
@@ -1849,9 +1849,9 @@ export default function contribute(plugin: any) {
|
||||
const child = createReloadChild("blocked", events);
|
||||
const runtime = createTestRuntime({ spawnChild: () => child });
|
||||
|
||||
await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow(
|
||||
"Plugin start cancelled: blocked",
|
||||
);
|
||||
await expect(
|
||||
runtime.startPlugin("blocked", directory, () => events.length === 0),
|
||||
).rejects.toThrow("Plugin start cancelled: blocked");
|
||||
|
||||
expect(runtime.catalog()).toEqual([]);
|
||||
expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]);
|
||||
@@ -2055,3 +2055,50 @@ export default function contribute(server) {
|
||||
await runtime.stopAll();
|
||||
}
|
||||
});
|
||||
|
||||
const examplesDirectory = fileURLToPath(
|
||||
new URL("../../../../../plugin-examples/", import.meta.url),
|
||||
);
|
||||
// The Markdown example owns npm preparation in the real app fixture:
|
||||
// packages/app/e2e/browser/plugin-file-panel-examples.spec.ts.
|
||||
const examples = (await readdir(examplesDirectory, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory() && entry.name !== "markdown-panel")
|
||||
.map((entry) => entry.name);
|
||||
|
||||
it.each(examples)("builds and loads the %s plugin example", async (example) => {
|
||||
const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-"));
|
||||
temporaryDirectories.push(settingsDirectory);
|
||||
const runtime = createTestRuntime({ settingsDirectory }, undefined, "0.13.2");
|
||||
try {
|
||||
await runtime.startPlugin(example, path.join(examplesDirectory, example));
|
||||
expect(runtime.catalog()).toMatchObject([{ id: example }]);
|
||||
expect(pluginMessages(runtime, example)).toContain("[paseo] Plugin ready");
|
||||
} finally {
|
||||
await runtime.stopAll();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not initialize a plugin cancelled during compilation", async () => {
|
||||
const directory = await createPlugin("cancelled-build", "export default () => {};");
|
||||
const marker = path.join(directory, "initialized.txt");
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import { writeFileSync } from "node:fs";
|
||||
export default function contribute() {
|
||||
writeFileSync(${JSON.stringify(marker)}, "initialized");
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const runtime = createTestRuntime();
|
||||
let allowed = true;
|
||||
try {
|
||||
const loading = runtime.startPlugin("cancelled-build", directory, () => allowed);
|
||||
allowed = false;
|
||||
await expect(loading).rejects.toThrow("Plugin start cancelled: cancelled-build");
|
||||
expect(await readTextIfPresent(marker)).toBeNull();
|
||||
expect(runtime.catalog()).toEqual([]);
|
||||
} finally {
|
||||
await runtime.stopAll();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server";
|
||||
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
|
||||
import { fork } from "node:child_process";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -22,10 +21,8 @@ import {
|
||||
type ProviderInput,
|
||||
} from "@getpaseo/plugin/server/provider";
|
||||
import type { PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import { compilePlugin } from "./compiler.js";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import { compileDirectoryPlugin } from "./compiler.js";
|
||||
import type { PluginRequirements } from "@getpaseo/protocol/messages";
|
||||
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
|
||||
import type {
|
||||
PluginProcessMessage,
|
||||
PluginProcessRequest,
|
||||
@@ -37,8 +34,6 @@ import { PluginSessionSocket } from "./session-socket.js";
|
||||
import { InternalPluginChild } from "./internal-child.js";
|
||||
import { evaluateBundle } from "./bundle-evaluator.js";
|
||||
|
||||
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
|
||||
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MAX_LOG_ENTRIES = 500;
|
||||
const MAX_LOG_BYTES = 256 * 1024;
|
||||
@@ -265,35 +260,6 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
|
||||
for (const filename of filenames) {
|
||||
const filePath = path.join(directory, filename);
|
||||
const info = await stat(filePath).catch(() => null);
|
||||
if (info?.isFile()) return filePath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveEntryPaths(directory: string): Promise<{
|
||||
client: string | null;
|
||||
server: string | null;
|
||||
}> {
|
||||
const [client, server] = await Promise.all([
|
||||
findEntry(directory, CLIENT_ENTRY_FILENAMES),
|
||||
findEntry(directory, SERVER_ENTRY_FILENAMES),
|
||||
]);
|
||||
if (client || server) return { client, server };
|
||||
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
|
||||
if (legacyEntry) {
|
||||
throw new Error(
|
||||
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
|
||||
);
|
||||
}
|
||||
|
||||
export class PluginRuntime {
|
||||
private readonly plugins = new Map<string, LoadedPlugin>();
|
||||
private readonly pendingEvents = new Set<Promise<void>>();
|
||||
@@ -331,10 +297,16 @@ export class PluginRuntime {
|
||||
): Promise<void> {
|
||||
if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`);
|
||||
this.appendLog(pluginId, "stdout", "[paseo] Loading plugin");
|
||||
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => {
|
||||
this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`);
|
||||
throw error;
|
||||
});
|
||||
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath, canPublish).catch(
|
||||
(error) => {
|
||||
this.appendLog(
|
||||
pluginId,
|
||||
"stderr",
|
||||
`[paseo] Plugin failed to load: ${describeError(error)}`,
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
if (!canPublish()) {
|
||||
await this.stopPlugin(loaded);
|
||||
throw new Error(`Plugin start cancelled: ${pluginId}`);
|
||||
@@ -347,12 +319,8 @@ export class PluginRuntime {
|
||||
if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`);
|
||||
this.appendLog(input.id, "stdout", "[paseo] Loading plugin");
|
||||
const directory = path.resolve(input.directory);
|
||||
const manifest = await readPluginManifest(directory);
|
||||
if (manifest.id !== input.id) {
|
||||
throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`);
|
||||
}
|
||||
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
||||
const bundles = await compilePlugin(await resolveEntryPaths(directory));
|
||||
const bundles = await compileDirectoryPlugin(directory, this.daemonVersion, input.id);
|
||||
const { manifest } = bundles;
|
||||
if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`);
|
||||
const loaded = await this.launchPlugin({
|
||||
pluginId: input.id,
|
||||
@@ -367,11 +335,7 @@ export class PluginRuntime {
|
||||
}
|
||||
|
||||
async validatePlugin(configuredPath: string): Promise<void> {
|
||||
const directory = path.resolve(configuredPath);
|
||||
const manifest = await readPluginManifest(directory);
|
||||
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
||||
const entryPaths = await resolveEntryPaths(directory);
|
||||
await compilePlugin(entryPaths);
|
||||
await compileDirectoryPlugin(path.resolve(configuredPath), this.daemonVersion);
|
||||
}
|
||||
|
||||
async stopPluginById(pluginId: string): Promise<boolean> {
|
||||
@@ -631,12 +595,12 @@ export class PluginRuntime {
|
||||
private async loadDirectoryPlugin(
|
||||
pluginId: string,
|
||||
configuredPath: string,
|
||||
canPublish: () => boolean,
|
||||
): Promise<LoadedPlugin> {
|
||||
const directory = path.resolve(configuredPath);
|
||||
const manifest = await readPluginManifest(directory);
|
||||
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
||||
const entryPaths = await resolveEntryPaths(directory);
|
||||
const bundles = await compilePlugin(entryPaths);
|
||||
const bundles = await compileDirectoryPlugin(directory, this.daemonVersion);
|
||||
const { manifest } = bundles;
|
||||
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
|
||||
const serverBundle = bundles.serverBundle;
|
||||
if (!serverBundle) {
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chmod, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
@@ -52,3 +53,30 @@ function resolveSymlinkedFile(filePath: string): string {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensurePrivateFileAsync(filePath: string): Promise<void> {
|
||||
if (process.platform === "win32") return;
|
||||
await chmod(filePath, PRIVATE_FILE_MODE).catch(() => {});
|
||||
}
|
||||
|
||||
export async function writePrivateFileAtomic(filePath: string, data: string): Promise<void> {
|
||||
const directory = path.dirname(filePath);
|
||||
await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
||||
if (process.platform !== "win32") await chmod(directory, PRIVATE_DIRECTORY_MODE).catch(() => {});
|
||||
const destination = await realpath(filePath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return filePath;
|
||||
throw error;
|
||||
});
|
||||
const temporary = path.join(
|
||||
path.dirname(destination),
|
||||
`.${path.basename(destination)}.${process.pid}.${randomUUID()}`,
|
||||
);
|
||||
try {
|
||||
await writeFile(temporary, data, { mode: PRIVATE_FILE_MODE });
|
||||
await rename(temporary, destination);
|
||||
await ensurePrivateFileAsync(destination);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
github: asGitHubService(github),
|
||||
workspaceGitService: asWorkspaceGitService(workspaceGitService),
|
||||
daemonConfigStore: asDaemonConfigStore({
|
||||
readServicePorts: async () => undefined,
|
||||
get: vi.fn(() => ({
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
|
||||
@@ -81,7 +81,6 @@ import {
|
||||
type WorkspaceScriptsService,
|
||||
} from "./session/workspace-scripts/workspace-scripts-service.js";
|
||||
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||
@@ -1232,7 +1231,7 @@ export class Session {
|
||||
spawnWorkspaceScript,
|
||||
assertAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts,
|
||||
getGlobalServicePorts: () => this.daemonConfigStore.readServicePorts(),
|
||||
});
|
||||
this.workspaceDirectory = new WorkspaceDirectory({
|
||||
logger: this.sessionLogger,
|
||||
@@ -2927,8 +2926,7 @@ export class Session {
|
||||
case "daemon.get_pairing_offer.request":
|
||||
return this.daemonSession.handleGetPairingOfferRequest(msg);
|
||||
case "daemon.config.reload.request":
|
||||
this.daemonSession.handleConfigReloadRequest(msg);
|
||||
return undefined;
|
||||
return this.daemonSession.handleConfigReloadRequest(msg);
|
||||
case "hub.management.daemon.connect.request":
|
||||
case "hub.management.daemon.get_status.request":
|
||||
case "hub.management.daemon.disconnect.request":
|
||||
@@ -2939,14 +2937,15 @@ export class Session {
|
||||
case "daemon.update.request":
|
||||
return this.daemonSession.handleUpdateRequest(msg);
|
||||
case "set_daemon_config_request":
|
||||
this.emit({
|
||||
type: "set_daemon_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
config: this.daemonConfigStore.patch(msg.config),
|
||||
},
|
||||
return this.daemonConfigStore.patch(msg.config).then((config) => {
|
||||
return this.emit({
|
||||
type: "set_daemon_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
config,
|
||||
},
|
||||
});
|
||||
});
|
||||
return undefined;
|
||||
case "read_project_config_request":
|
||||
return this.projectConfigSession.handleReadProjectConfigRequest(msg);
|
||||
case "write_project_config_request":
|
||||
@@ -7787,6 +7786,8 @@ export class Session {
|
||||
): Promise<void> {
|
||||
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
|
||||
const projection = "projected" as const;
|
||||
const requestedLimit = msg.limit;
|
||||
const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
|
||||
const cursor: AgentTimelineCursor | undefined = msg.cursor
|
||||
? {
|
||||
epoch: msg.cursor.epoch,
|
||||
@@ -7805,7 +7806,7 @@ export class Session {
|
||||
const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
|
||||
direction,
|
||||
cursor,
|
||||
limit: msg.limit,
|
||||
limit: pageLimit,
|
||||
});
|
||||
const selectedTimeline = {
|
||||
timeline: fetchedControlTimeline,
|
||||
|
||||
@@ -43,7 +43,7 @@ function makeSubsystem(overrides: {
|
||||
listProviderAvailability?: () => Promise<ProviderAvailability[]>;
|
||||
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
|
||||
hubRelationships?: HubRelationshipManagement;
|
||||
reloadConfig?: () => DaemonConfigReloadResult;
|
||||
reloadConfig?: () => Promise<DaemonConfigReloadResult>;
|
||||
}) {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = [];
|
||||
@@ -67,7 +67,7 @@ function makeSubsystem(overrides: {
|
||||
hubRelationships: overrides.hubRelationships,
|
||||
reloadConfig:
|
||||
overrides.reloadConfig ??
|
||||
(() => ({
|
||||
(async () => ({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -78,16 +78,16 @@ function makeSubsystem(overrides: {
|
||||
}
|
||||
|
||||
describe("DaemonSession", () => {
|
||||
test("config reload returns the daemon-owned classification", () => {
|
||||
test("config reload returns the daemon-owned classification", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
reloadConfig: () => ({
|
||||
reloadConfig: async () => ({
|
||||
appliedPaths: ["daemon.browserTools.enabled"],
|
||||
restartRequiredPaths: ["daemon.listen"],
|
||||
overrideControlledPaths: ["app.baseUrl"],
|
||||
}),
|
||||
});
|
||||
|
||||
subsystem.handleConfigReloadRequest({
|
||||
await subsystem.handleConfigReloadRequest({
|
||||
type: "daemon.config.reload.request",
|
||||
requestId: "reload-1",
|
||||
});
|
||||
@@ -105,14 +105,14 @@ describe("DaemonSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("config reload failures return a correlated RPC error", () => {
|
||||
test("config reload failures return a correlated RPC error", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
reloadConfig: () => {
|
||||
reloadConfig: async () => {
|
||||
throw new Error("Invalid config");
|
||||
},
|
||||
});
|
||||
|
||||
subsystem.handleConfigReloadRequest({
|
||||
await subsystem.handleConfigReloadRequest({
|
||||
type: "daemon.config.reload.request",
|
||||
requestId: "reload-2",
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface DaemonSessionOptions {
|
||||
getObservationMetrics?: () => Record<string, number>;
|
||||
logger: pino.Logger;
|
||||
hubRelationships?: HubRelationshipManagement;
|
||||
reloadConfig: () => DaemonConfigReloadResult;
|
||||
reloadConfig: () => Promise<DaemonConfigReloadResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +79,7 @@ export class DaemonSession {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly selfUpdate: DaemonSelfUpdateSessionController;
|
||||
private readonly hubRelationships: HubRelationshipManagement | null;
|
||||
private readonly reloadConfig: () => DaemonConfigReloadResult;
|
||||
private readonly reloadConfig: () => Promise<DaemonConfigReloadResult>;
|
||||
|
||||
constructor(options: DaemonSessionOptions) {
|
||||
this.host = options.host;
|
||||
@@ -252,13 +252,13 @@ export class DaemonSession {
|
||||
}
|
||||
}
|
||||
|
||||
handleConfigReloadRequest(
|
||||
async handleConfigReloadRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "daemon.config.reload.request" }>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.host.emit({
|
||||
type: "daemon.config.reload.response",
|
||||
payload: { requestId: msg.requestId, ...this.reloadConfig() },
|
||||
payload: { requestId: msg.requestId, ...(await this.reloadConfig()) },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error }, "Failed to reload daemon config");
|
||||
|
||||
+22
@@ -1,3 +1,5 @@
|
||||
import { DaemonConfigStore } from "../../daemon-config-store.js";
|
||||
import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -82,6 +84,7 @@ interface BuildOptions {
|
||||
spawnThrows?: string;
|
||||
runtimeStore?: Pick<WorkspaceRuntimeStore, "get">;
|
||||
automationError?: Error;
|
||||
getGlobalServicePorts?: () => Promise<PaseoServicePortAllocation | undefined>;
|
||||
}
|
||||
|
||||
function buildService(options: BuildOptions = {}) {
|
||||
@@ -111,6 +114,7 @@ function buildService(options: BuildOptions = {}) {
|
||||
getDaemonTcpHost: () => "127.0.0.1",
|
||||
serviceProxyPublicBaseUrl: null,
|
||||
resolveScriptHealth: null,
|
||||
getGlobalServicePorts: options.getGlobalServicePorts ?? (async () => undefined),
|
||||
logger,
|
||||
emit: (message) => emitted.push(message),
|
||||
publishStatusUpdate: (message) => published.push(message),
|
||||
@@ -152,6 +156,24 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
test("reads external service-port edits at the next launch without construction IO", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "workspace-service-ports-"));
|
||||
tempDirs.push(home);
|
||||
const store = new DaemonConfigStore(home, {
|
||||
providers: {},
|
||||
mcp: { enabled: true, injectIntoAgents: false },
|
||||
});
|
||||
const { service, spawnCalls } = buildService({
|
||||
getGlobalServicePorts: () => store.readServicePorts(),
|
||||
});
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({ worktrees: { servicePorts: { range: "9000-9999" } } }),
|
||||
);
|
||||
await service.start(request);
|
||||
expect(spawnCalls.map((call) => call.globalServicePorts)).toEqual([{ range: "9000-9999" }]);
|
||||
});
|
||||
|
||||
describe("buildSnapshot", () => {
|
||||
test("returns no scripts when the service proxy is unavailable", async () => {
|
||||
const { service } = buildService({ serviceProxy: null });
|
||||
|
||||
@@ -62,7 +62,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
getDaemonTcpHost: (() => string | null) | null;
|
||||
serviceProxyPublicBaseUrl: string | null;
|
||||
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
|
||||
globalServicePorts?: PaseoServicePortAllocation;
|
||||
getGlobalServicePorts: () => Promise<PaseoServicePortAllocation | undefined>;
|
||||
logger: pino.Logger;
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
// Script status is daemon state, so it reaches every client, not only the requester.
|
||||
@@ -81,7 +81,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
resolveScriptHealth,
|
||||
globalServicePorts,
|
||||
getGlobalServicePorts,
|
||||
logger,
|
||||
emit,
|
||||
publishStatusUpdate,
|
||||
@@ -172,6 +172,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
|
||||
async function launchProcess(input: { workspaceId: string; scriptName: string }) {
|
||||
const available = requireAvailable();
|
||||
const globalServicePorts = await getGlobalServicePorts();
|
||||
const workspace = await getWorkspace(input.workspaceId);
|
||||
await assertAutomationAllowed(workspace.workspaceId);
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("applyTerminalAgentHookSetting", () => {
|
||||
expect(existsSync(paths.opencode)).toBe(true);
|
||||
});
|
||||
|
||||
it("installs on enable and removes hooks on disable when toggled live", () => {
|
||||
it("installs on enable and removes hooks on disable when toggled live", async () => {
|
||||
const root = createTempDir("paseo-hook-setting-");
|
||||
const store = createStore(createTempDir("paseo-hook-setting-home-"), false);
|
||||
const paths = hookPaths(root);
|
||||
@@ -88,11 +88,11 @@ describe("applyTerminalAgentHookSetting", () => {
|
||||
applyTerminalAgentHookSetting({ store, install: createInstallEnv(root) });
|
||||
expect(existsSync(paths.opencode)).toBe(false);
|
||||
|
||||
store.patch({ enableTerminalAgentHooks: true });
|
||||
await store.patch({ enableTerminalAgentHooks: true });
|
||||
expect(existsSync(paths.codex)).toBe(true);
|
||||
expect(existsSync(paths.opencode)).toBe(true);
|
||||
|
||||
store.patch({ enableTerminalAgentHooks: false });
|
||||
await store.patch({ enableTerminalAgentHooks: false });
|
||||
expect(existsSync(paths.opencode)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user