mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3cf9d69a | ||
|
|
ae6910cadb | ||
|
|
a9a140f6ea | ||
|
|
555191f95d | ||
|
|
b86cbd38e5 |
@@ -1,50 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { runPaseoCli, startTestDaemon } from "../../../tests/helpers/test-daemon.js";
|
||||
|
||||
describe("paseo logs JSON output", () => {
|
||||
let daemon: Awaited<ReturnType<typeof startTestDaemon>>;
|
||||
let agentId: string;
|
||||
let host: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
daemon = await startTestDaemon({
|
||||
paseoHome: await mkdtemp(resolve(".artifacts/logs-json-home-")),
|
||||
workDir: await mkdtemp(resolve(".artifacts/logs-json-work-")),
|
||||
});
|
||||
host = `127.0.0.1:${daemon.port}`;
|
||||
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
|
||||
try {
|
||||
// Creating an idle agent needs no inference or external provider connection.
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.workDir,
|
||||
title: "JSON logs",
|
||||
});
|
||||
agentId = agent.id;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await daemon?.stop();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "top-level --json", args: ["logs", "--json"] },
|
||||
{ label: "agent logs --json", args: ["agent", "logs", "--json"] },
|
||||
{ label: "--format json alias", args: ["logs", "--format", "json"] },
|
||||
])(
|
||||
"prints JSON through $label",
|
||||
async ({ args }) => {
|
||||
const result = await runPaseoCli(daemon, [...args, agentId, "--host", host]);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ agentId, items: [] });
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
createFollowJsonWriter,
|
||||
createFollowTranscriptWriter,
|
||||
formatAgentActivityTranscript,
|
||||
} from "./logs.js";
|
||||
import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js";
|
||||
|
||||
function collect(items: readonly AgentTimelineItem[], turnId?: string): string {
|
||||
let output = "";
|
||||
@@ -153,31 +149,3 @@ describe("createFollowTranscriptWriter", () => {
|
||||
expect(output).toBe("# Title\n\nBody\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFollowJsonWriter", () => {
|
||||
it("writes one JSON item per line and honours the filter", () => {
|
||||
let output = "";
|
||||
const writer = createFollowJsonWriter("tools", (chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
const toolCall = {
|
||||
type: "tool_call",
|
||||
callId: "call_1",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
error: null,
|
||||
} as AgentTimelineItem;
|
||||
|
||||
writer.push({ type: "assistant_message", text: "Running it.", messageId: "msg_1" });
|
||||
writer.push(toolCall);
|
||||
writer.end();
|
||||
|
||||
expect(
|
||||
output
|
||||
.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual([toolCall]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,35 +70,6 @@ export function formatAgentActivityTranscript(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the items `--tail` keeps, the same way the transcript does: the last n
|
||||
* timeline items, before any of them are dropped from the curated view.
|
||||
*/
|
||||
function selectTailItems(
|
||||
timelineItems: AgentTimelineItem[],
|
||||
tailCount?: number,
|
||||
): AgentTimelineItem[] {
|
||||
if (tailCount === undefined) {
|
||||
return timelineItems;
|
||||
}
|
||||
if (tailCount === 0) {
|
||||
return [];
|
||||
}
|
||||
return timelineItems.slice(-tailCount);
|
||||
}
|
||||
|
||||
function formatAgentLogsJson(
|
||||
agentId: string,
|
||||
timelineItems: AgentTimelineItem[],
|
||||
tailCount?: number,
|
||||
): string {
|
||||
return JSON.stringify({ agentId, items: selectTailItems(timelineItems, tailCount) }, null, 2);
|
||||
}
|
||||
|
||||
function usesJsonOutput(options: AgentLogsOptions): boolean {
|
||||
return options.json === true || options.format?.trim().toLowerCase() === "json";
|
||||
}
|
||||
|
||||
type StreamingTextType = "assistant_message" | "reasoning";
|
||||
|
||||
const THOUGHT_PREFIX = "[Thought] ";
|
||||
@@ -227,21 +198,6 @@ export function createFollowTranscriptWriter(
|
||||
};
|
||||
}
|
||||
|
||||
/** Follow mode with `--json`: one JSON timeline item per line (NDJSON). */
|
||||
export function createFollowJsonWriter(
|
||||
filter?: string,
|
||||
write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk),
|
||||
): FollowTranscriptWriter {
|
||||
return {
|
||||
push(item) {
|
||||
if (matchesFilter(item, filter)) {
|
||||
write(`${JSON.stringify(item)}\n`);
|
||||
}
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTailCount(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
@@ -350,11 +306,6 @@ export async function runLogsCommand(
|
||||
|
||||
await client.close();
|
||||
|
||||
if (usesJsonOutput(options)) {
|
||||
console.log(formatAgentLogsJson(resolvedId, timelineItems, tailCount));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use curateAgentActivity to format the transcript
|
||||
if (tailCount === 0) {
|
||||
return;
|
||||
@@ -380,104 +331,78 @@ async function runFollowMode({
|
||||
options,
|
||||
sinceTimestampMs,
|
||||
}: FollowModeInput): Promise<void> {
|
||||
// Observe shutdown before history writes can encounter a closed reader.
|
||||
// Keep an early stop resolved while subscription setup is in flight.
|
||||
const stopController = new AbortController();
|
||||
const stopped = waitForStop(stopController.signal);
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const json = usesJsonOutput(options);
|
||||
const writer = json
|
||||
? createFollowJsonWriter(options.filter)
|
||||
: createFollowTranscriptWriter(undefined, options.filter);
|
||||
const DEFAULT_FOLLOW_TAIL = 10;
|
||||
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
|
||||
|
||||
// First, get existing timeline.
|
||||
let existingItems: AgentTimelineItem[] = [];
|
||||
try {
|
||||
const DEFAULT_FOLLOW_TAIL = 10;
|
||||
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
|
||||
|
||||
// First, get existing timeline.
|
||||
let existingItems: AgentTimelineItem[] = [];
|
||||
try {
|
||||
const history = await Promise.race([
|
||||
fetchAgentTimelineItems(client, agentId, {
|
||||
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
sinceTimestampMs,
|
||||
}),
|
||||
stopped,
|
||||
]);
|
||||
if (!history) return;
|
||||
existingItems = history;
|
||||
} catch (error) {
|
||||
console.warn("Warning: failed to fetch existing timeline", error);
|
||||
}
|
||||
|
||||
// Apply filter to existing items
|
||||
if (options.filter) {
|
||||
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
|
||||
}
|
||||
|
||||
// Print existing transcript (tail-like behavior)
|
||||
if (json) {
|
||||
for (const item of selectTailItems(existingItems, tailCount)) {
|
||||
console.log(JSON.stringify(item));
|
||||
}
|
||||
} else if (tailCount > 0) {
|
||||
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
|
||||
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
|
||||
console.log(existingTranscript);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to new events
|
||||
const tailLabel =
|
||||
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
|
||||
|
||||
// Keep stdout parseable in JSON mode: one timeline item per line, notices on stderr.
|
||||
const notice = json ? console.error : console.log;
|
||||
|
||||
const subscription = client.subscribeAgentTimeline(agentId, (message) => {
|
||||
if (message.type === "agent.timeline.replacement") {
|
||||
writer.end();
|
||||
notice("\n[Timeline replaced; earlier output is no longer current]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "agent.timeline.error") {
|
||||
writer.end();
|
||||
console.error(`Timeline observation stopped: ${message.payload.error}`);
|
||||
return;
|
||||
}
|
||||
if (message.type === "agent.timeline.subscription_restored") {
|
||||
writer.end();
|
||||
notice("\n[Reconnected; live output resumed. Events may have been missed.]");
|
||||
return;
|
||||
}
|
||||
if (message.payload.event.type === "timeline") {
|
||||
const matchesSince =
|
||||
sinceTimestampMs === undefined ||
|
||||
Date.parse(message.payload.timestamp) >= sinceTimestampMs;
|
||||
if (!matchesSince) {
|
||||
return;
|
||||
}
|
||||
const item = message.payload.event.item;
|
||||
writer.push(item, message.payload.event.turnId);
|
||||
} else if (
|
||||
message.payload.event.type === "turn_completed" ||
|
||||
message.payload.event.type === "turn_failed" ||
|
||||
message.payload.event.type === "turn_canceled"
|
||||
) {
|
||||
writer.end();
|
||||
}
|
||||
existingItems = await fetchAgentTimelineItems(client, agentId, {
|
||||
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
sinceTimestampMs,
|
||||
});
|
||||
|
||||
unsubscribe = subscription;
|
||||
const ready = await Promise.race([subscription.ready.then(() => true), stopped]);
|
||||
if (!ready) return;
|
||||
notice(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
await stopped;
|
||||
} finally {
|
||||
stopController.abort();
|
||||
writer.end();
|
||||
unsubscribe?.();
|
||||
await client.close();
|
||||
} catch (error) {
|
||||
console.warn("Warning: failed to fetch existing timeline", error);
|
||||
}
|
||||
|
||||
// Apply filter to existing items
|
||||
if (options.filter) {
|
||||
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
|
||||
}
|
||||
|
||||
// Print existing transcript (tail-like behavior)
|
||||
if (tailCount > 0) {
|
||||
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
|
||||
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
|
||||
console.log(existingTranscript);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to new events
|
||||
const tailLabel =
|
||||
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
|
||||
|
||||
const writer = createFollowTranscriptWriter(undefined, options.filter);
|
||||
|
||||
const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => {
|
||||
if (message.type === "agent.timeline.replacement") {
|
||||
writer.end();
|
||||
console.log("\n[Timeline replaced; earlier output is no longer current]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "agent.timeline.error") {
|
||||
writer.end();
|
||||
console.error(`Timeline observation stopped: ${message.payload.error}`);
|
||||
return;
|
||||
}
|
||||
if (message.type === "agent.timeline.subscription_restored") {
|
||||
writer.end();
|
||||
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
|
||||
return;
|
||||
}
|
||||
if (message.payload.event.type === "timeline") {
|
||||
const matchesSince =
|
||||
sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs;
|
||||
if (!matchesSince) {
|
||||
return;
|
||||
}
|
||||
const item = message.payload.event.item;
|
||||
writer.push(item, message.payload.event.turnId);
|
||||
} else if (
|
||||
message.payload.event.type === "turn_completed" ||
|
||||
message.payload.event.type === "turn_failed" ||
|
||||
message.payload.event.type === "turn_canceled"
|
||||
) {
|
||||
writer.end();
|
||||
}
|
||||
});
|
||||
|
||||
await unsubscribe.ready;
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
await waitForStop();
|
||||
writer.end();
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { buildAgentLsFetchOptions, listAgents } from "./ls.js";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
function createPagingClient(archivedAgentNumber?: number) {
|
||||
function createPagingClient() {
|
||||
const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({
|
||||
id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`,
|
||||
provider: "codex",
|
||||
title: `agent ${n}`,
|
||||
status: n === archivedAgentNumber ? "idle" : "closed",
|
||||
archivedAt: n === archivedAgentNumber ? "2026-10-10T00:00:00.000Z" : null,
|
||||
status: "closed",
|
||||
archivedAt: null,
|
||||
cwd: "/tmp/project",
|
||||
createdAt: `2026-10-0${n}T00:00:00.000Z`,
|
||||
updatedAt: `2026-10-0${n}T00:00:00.000Z`,
|
||||
@@ -130,28 +130,4 @@ describe("runLsCommand", () => {
|
||||
|
||||
expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]);
|
||||
});
|
||||
|
||||
it("marks archived agents in table and JSON list data", async () => {
|
||||
const result = await listAgents({ daemonTarget, all: true, global: true }, async () =>
|
||||
createPagingClient(2),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.data.map(({ name, archived, archivedAt }) => ({ name, archived, archivedAt })),
|
||||
).toEqual([
|
||||
{ name: "agent 2", archived: true, archivedAt: "2026-10-10T00:00:00.000Z" },
|
||||
{ name: "agent 3", archived: false, archivedAt: null },
|
||||
{ name: "agent 1", archived: false, archivedAt: null },
|
||||
]);
|
||||
|
||||
const archivedColumn = result.schema.columns.find((column) => column.header === "ARCHIVED");
|
||||
expect(archivedColumn).toBeDefined();
|
||||
expect(typeof archivedColumn?.field).toBe("function");
|
||||
if (!archivedColumn || typeof archivedColumn.field !== "function") {
|
||||
throw new Error("Expected the ARCHIVED column to have a display accessor");
|
||||
}
|
||||
expect(result.data[0]!.status).toBe("idle");
|
||||
expect(archivedColumn.field(result.data[0]!)).toBe("yes");
|
||||
expect(archivedColumn.field(result.data[1]!)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,8 +33,6 @@ export interface AgentListItem {
|
||||
provider: string;
|
||||
thinking: string;
|
||||
status: string;
|
||||
archived: boolean;
|
||||
archivedAt: string | null;
|
||||
cwd: string;
|
||||
created: string;
|
||||
}
|
||||
@@ -86,11 +84,6 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "ARCHIVED",
|
||||
field: (item) => (item.archived ? "yes" : "-"),
|
||||
width: 8,
|
||||
},
|
||||
{ header: "CWD", field: "cwd", width: 30 },
|
||||
{ header: "CREATED", field: "created", width: 15 },
|
||||
],
|
||||
@@ -106,8 +99,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
provider: model ? `${agent.provider}/${model}` : agent.provider,
|
||||
thinking: agent.effectiveThinkingOptionId ?? "auto",
|
||||
status: agent.status,
|
||||
archived: agent.archivedAt != null,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
cwd: shortenPath(agent.cwd),
|
||||
created: relativeTime(agent.createdAt),
|
||||
};
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
// their output closes its end, as `| head` does. A closed reader shows up as
|
||||
// EPIPE on the next write to stdout, so the command stops at its next line of
|
||||
// output.
|
||||
export function waitForStop(signal?: AbortSignal): Promise<void> {
|
||||
export function waitForStop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const stop = () => {
|
||||
signal?.removeEventListener("abort", stop);
|
||||
process.off("SIGINT", stop);
|
||||
process.off("SIGTERM", stop);
|
||||
process.stdout.off("error", stopOnClosedStdout);
|
||||
@@ -21,7 +20,5 @@ export function waitForStop(signal?: AbortSignal): Promise<void> {
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
process.stdout.on("error", stopOnClosedStdout);
|
||||
signal?.addEventListener("abort", stop, { once: true });
|
||||
if (signal?.aborted) stop();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
@@ -140,225 +137,3 @@ test("omitted after limit pages forward while explicit zero preserves complete C
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.each([
|
||||
{ label: "--json", flags: ["--json"] },
|
||||
{ label: "--format json", flags: ["--format", "json"] },
|
||||
])(
|
||||
"logs --follow $label keeps history, live items and notices on their channels",
|
||||
async ({ flags }) => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-json-"));
|
||||
const staticDir = join(paseoHomeRoot, "static");
|
||||
await mkdir(staticDir);
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let follow: ReturnType<typeof spawn> | undefined;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
try {
|
||||
await client.connect();
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
const history = [
|
||||
{ type: "user_message" as const, text: "Saved JSON prompt" },
|
||||
{ type: "assistant_message" as const, text: "Saved JSON reply", messageId: "saved" },
|
||||
];
|
||||
for (const item of history)
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, item);
|
||||
follow = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"logs",
|
||||
agent.id,
|
||||
"--follow",
|
||||
...flags,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const exited = once(follow, "exit");
|
||||
follow.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
follow.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
if (follow?.exitCode !== null)
|
||||
throw new Error(`CLI exited before following logs: ${stderr}`);
|
||||
return stdout + stderr;
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.toContain("Following logs");
|
||||
const live = {
|
||||
type: "assistant_message" as const,
|
||||
text: "Live JSON reply",
|
||||
messageId: "live",
|
||||
};
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, live);
|
||||
await expect.poll(() => stdout, { timeout: 10_000 }).toContain(live.text);
|
||||
follow.kill("SIGINT");
|
||||
await exited;
|
||||
expect(
|
||||
stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual([...history, live]);
|
||||
expect(stderr).toContain("Following logs");
|
||||
expect(stdout).not.toContain("Following logs");
|
||||
} finally {
|
||||
follow?.kill("SIGTERM");
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ label: "--json", command: ["logs"], flags: ["--json"] },
|
||||
{ label: "--format json", command: ["agent", "logs"], flags: ["--format", "json"] },
|
||||
])(
|
||||
"logs --follow $label exits when stdout closes before saved history is written",
|
||||
async ({ command, flags }) => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-closed-"));
|
||||
const staticDir = join(paseoHomeRoot, "static");
|
||||
await mkdir(staticDir);
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let follow: ReturnType<typeof spawn> | undefined;
|
||||
let stderr = "";
|
||||
try {
|
||||
await client.connect();
|
||||
// No turn is started: the saved history must trigger shutdown on its own.
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: "Saved reply before the reader closes",
|
||||
messageId: "saved",
|
||||
});
|
||||
follow = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
...command,
|
||||
agent.id,
|
||||
"--follow",
|
||||
...flags,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const exited = once(follow, "exit");
|
||||
follow.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
follow.stdout?.destroy();
|
||||
await expect.poll(() => follow?.exitCode, { timeout: 10_000 }).toBe(0);
|
||||
expect(await exited, stderr).toEqual([0, null]);
|
||||
} finally {
|
||||
follow?.kill("SIGKILL");
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
// Windows kill("SIGINT") force-terminates rather than delivering a console interrupt.
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"logs --follow stops on SIGINT while subscription startup is waiting",
|
||||
async () => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-startup-"));
|
||||
const staticDir = join(paseoHomeRoot, "static");
|
||||
await mkdir(staticDir);
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const proxy = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await once(proxy, "listening");
|
||||
const address = proxy.address();
|
||||
if (typeof address === "string" || address === null) throw new Error("Missing proxy port");
|
||||
const upstreams: WebSocket[] = [];
|
||||
let waitingForSubscription = false;
|
||||
proxy.on("connection", (downstream) => {
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${daemon.port}/ws`);
|
||||
upstreams.push(upstream);
|
||||
const connected = once(upstream, "open");
|
||||
downstream.on("message", async (data, binary) => {
|
||||
await connected;
|
||||
const frame = binary ? undefined : JSON.parse(data.toString());
|
||||
// Hold only the subscription request. All other traffic uses the real daemon.
|
||||
if (frame?.message?.type === "agent.timeline.set_subscription.request") {
|
||||
waitingForSubscription = true;
|
||||
return;
|
||||
}
|
||||
upstream.send(data, { binary });
|
||||
});
|
||||
upstream.on("message", (data, binary) => downstream.send(data, { binary }));
|
||||
downstream.on("close", () => upstream.close());
|
||||
});
|
||||
let follow: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
follow = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${address.port}`,
|
||||
"logs",
|
||||
agent.id,
|
||||
"--follow",
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
follow.stdout?.resume();
|
||||
follow.stderr?.resume();
|
||||
const exited = once(follow, "exit");
|
||||
await expect.poll(() => waitingForSubscription, { timeout: 10_000 }).toBe(true);
|
||||
follow.kill("SIGINT");
|
||||
await expect.poll(() => follow?.exitCode, { timeout: 5_000 }).toBe(0);
|
||||
expect(await exited).toEqual([0, null]);
|
||||
} finally {
|
||||
if (follow && follow.exitCode === null && follow.signalCode === null) {
|
||||
follow.kill("SIGKILL");
|
||||
await once(follow, "exit");
|
||||
}
|
||||
for (const upstream of upstreams) upstream.terminate();
|
||||
for (const downstream of proxy.clients) downstream.terminate();
|
||||
await new Promise<void>((done) => proxy.close(() => done()));
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
@@ -36,6 +36,23 @@ function containsPath(directory: string, filePath: string): boolean {
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
// @types/node can declare builtins newer than the host Node (node:stream/iter on Node 24).
|
||||
function isNodeSpecifier(specifier: string): boolean {
|
||||
return isBuiltin(specifier) || specifier.startsWith("node:") || specifier === "@types/node";
|
||||
}
|
||||
|
||||
function declaredPackage(input: {
|
||||
specifier: string;
|
||||
kind: PluginImportKind;
|
||||
typeOnly: boolean;
|
||||
}): string {
|
||||
const { specifier, kind, typeOnly } = input;
|
||||
if (kind === "type-reference") return `@types/${specifier.replace(/^@/, "").replace("/", "__")}`;
|
||||
// Declarations load Node's ambient types with `import "node"`.
|
||||
if (typeOnly && specifier === "node") return "@types/node";
|
||||
return specifier;
|
||||
}
|
||||
|
||||
function dependencyName(specifier: string): string {
|
||||
const segments = specifier.split("/");
|
||||
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
|
||||
@@ -180,10 +197,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
|
||||
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 packageSpecifier = declaredPackage({ specifier, kind, typeOnly });
|
||||
const error =
|
||||
runtimeSpecifierError(specifier, owner, file) ??
|
||||
runtimeSpecifierError(packageSpecifier, owner, file) ??
|
||||
@@ -197,7 +211,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
|
||||
if (
|
||||
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
|
||||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
|
||||
isBuiltin(specifier) ||
|
||||
isNodeSpecifier(specifier) ||
|
||||
packageSpecifier === "@types/node"
|
||||
)
|
||||
continue;
|
||||
@@ -288,8 +302,7 @@ function runtimeSpecifierError(
|
||||
!(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" && isNodeSpecifier(specifier)) kind = "Node";
|
||||
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
|
||||
else if (
|
||||
target !== "client" &&
|
||||
|
||||
@@ -389,6 +389,46 @@ export type Value = string;`,
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: "import 'node' with Node types installed", source: 'import "node";', nodeTypes: true },
|
||||
{ name: "import 'node' without Node types", source: 'import "node";', nodeTypes: false },
|
||||
{
|
||||
name: "a node: builtin unknown to the host",
|
||||
source: 'import type { Future } from "node:paseo-future-builtin";',
|
||||
nodeTypes: false,
|
||||
},
|
||||
])("treats Node declarations as host types in server dependencies: $name", async (fixture) => {
|
||||
const entries = await createSplitPlugin();
|
||||
const dependency = path.join(entries.directory, "node_modules/image-codec");
|
||||
await mkdir(dependency, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dependency, "package.json"),
|
||||
JSON.stringify({ name: "image-codec", main: "index.js", types: "index.d.ts" }),
|
||||
);
|
||||
await writeFile(path.join(dependency, "index.js"), 'module.exports = "decoded";');
|
||||
await writeFile(
|
||||
path.join(dependency, "index.d.ts"),
|
||||
`${fixture.source}\ndeclare const value: string;\nexport = value;`,
|
||||
);
|
||||
if (fixture.nodeTypes) {
|
||||
const nodeTypes = path.join(entries.directory, "node_modules/@types/node");
|
||||
await mkdir(nodeTypes, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(nodeTypes, "package.json"),
|
||||
JSON.stringify({ name: "@types/node", types: "index.d.ts" }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(nodeTypes, "index.d.ts"),
|
||||
'declare module "node:paseo-future-builtin" { export * from "node:stream"; }\nimport type { Future } from "node:paseo-future-builtin";',
|
||||
);
|
||||
}
|
||||
await writeFile(entries.server, 'import value from "image-codec"; export default value;');
|
||||
|
||||
await expect(compilePlugin({ client: null, server: entries.server })).resolves.toMatchObject({
|
||||
serverBundle: expect.stringContaining("decoded"),
|
||||
});
|
||||
});
|
||||
|
||||
it("checks the physical owner of type-only symlinks", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
await writeFile(
|
||||
|
||||
Reference in New Issue
Block a user