mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app that manages AI agents for work > - Sandbox providers let agents run in remote and isolated environments > - Daytona session commands need a path that sends agent output to the host without host polling > - Host polling adds delay and repeats provider output work > - This pull request adds typed execute.log notifications and a log sink for incremental output > - This pull request adds an optional ACP session stream with final-result replay protection > - The benefit is lower output delay while the default flags keep current behavior unchanged ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting. The change spans the plugin SDK, Daytona provider, adapter utilities, and server execution services. **Problem or motivation** The Daytona ACP bridge polls a host output file while an agent command runs. This adds delay and can repeat work. The host also needs a safe route for provider output chunks. **Proposed solution** Add a typed `execute.log` notification with host-issued invocation correlation. Add an ordered log sink to the environment execute path. Add an optional ACP session-log path that parses newline-delimited JSON frames and removes the host output poll for that path. **Alternatives considered** Keep the output-file poll as the only path. This keeps the current behavior but does not provide timely output. The new path stays behind flags, so the existing path remains the default fallback. **Roadmap alignment** This change supports the shipped Cloud / Sandbox agents milestone in `ROADMAP.md`, including Daytona support. ## What Changed - Add the typed `execute.log` worker-to-host notification and company-scoped host route. - Add ordered `stdout` and `stderr` chunk delivery before the final execute result. - Add the Daytona session log sink and the optional ACP streamed session path. - Add monotonic frame handling so live and final output reach the host once. - Keep `useLogStream` and `streamAgentSessionOutput` off by default. - Add unit and integration coverage for the notification, execution target, runtime, and Daytona paths. ## Verification - Run adapter-utils tests: 445 tests pass locally. - Run server environment tests: 73 tests pass locally. - Run Daytona plugin tests: 131 tests pass locally. - Run TypeScript checks for shared, adapter-utils, and server. - Review the pull request checks after GitHub completes them. - All required GitHub checks pass on the current head. ## Risks The new paths change output delivery only when a feature flag enables them. The final execute result remains available for parsing and fallback. The main risk is a provider stream or frame-order error; the final-result parser limits that risk. ## Model Used OpenAI Codex, GPT-5, tool use and code execution. The runtime did not supply a context-window value. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes No operator documentation change applies because both new flags remain disabled by default. - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
118 lines
3.9 KiB
JavaScript
118 lines
3.9 KiB
JavaScript
// Test worker fixture for the `execute.log` worker→host notification route.
|
|
//
|
|
// On `environmentExecute` the fixture emits the `execute.log` notifications
|
|
// listed in `params.logs`, then returns the final result. Each log entry sets
|
|
// the envelope invocation id by its `tag`:
|
|
// - "echo" → the real host-issued id (the normal streaming path)
|
|
// - "unknown" → a forged id with no active route (must be dropped)
|
|
// - "none" → no id at all (must be dropped)
|
|
// - "forge-previous" → the id of the PREVIOUS execute call the same worker
|
|
// process handled. One worker process sees every active
|
|
// invocation id, so this reproduces a worker that runs
|
|
// company A and forges company B's active id.
|
|
// The default tag is "echo". The entry `stream` and `chunk` pass through
|
|
// verbatim, so a test can send an invalid stream name or an empty chunk to
|
|
// prove the host drops invalid input.
|
|
//
|
|
// When `params.oversizedLogChunkChars > 0` the fixture emits one VALID
|
|
// `execute.log` note (own id) whose chunk holds that many characters BEFORE the
|
|
// normal logs, so a test can prove the host drops an over-length line before it
|
|
// parses the JSON.
|
|
const readline = require("node:readline");
|
|
|
|
function send(message) {
|
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
}
|
|
|
|
// The id of the previous execute call this worker process handled. A single
|
|
// worker process serves every company, so it can name another company's active
|
|
// invocation. The fixture uses this to forge a peer id.
|
|
let previousInvocationId = null;
|
|
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
rl.on("line", (line) => {
|
|
if (!line.trim()) return;
|
|
const message = JSON.parse(line);
|
|
const method = message && typeof message.method === "string" ? message.method : null;
|
|
|
|
if (method === "initialize") {
|
|
send({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: { ok: true, supportedMethods: ["environmentExecute"] },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "environmentExecute") {
|
|
const invocationId = message.paperclipInvocation && message.paperclipInvocation.id;
|
|
const forgeableId = previousInvocationId;
|
|
previousInvocationId = invocationId;
|
|
const params = message.params ?? {};
|
|
const oversizedChars = Number(params.oversizedLogChunkChars ?? 0);
|
|
if (oversizedChars > 0) {
|
|
send({
|
|
jsonrpc: "2.0",
|
|
method: "execute.log",
|
|
paperclipInvocationId: invocationId,
|
|
params: { stream: "stdout", chunk: "d".repeat(oversizedChars) },
|
|
});
|
|
}
|
|
const logs = Array.isArray(params.logs) ? params.logs : [];
|
|
for (const entry of logs) {
|
|
const note = {
|
|
jsonrpc: "2.0",
|
|
method: "execute.log",
|
|
params: { stream: entry.stream, chunk: entry.chunk },
|
|
};
|
|
const tag = entry.tag ?? "echo";
|
|
if (tag === "echo") {
|
|
note.paperclipInvocationId = invocationId;
|
|
} else if (tag === "unknown") {
|
|
note.paperclipInvocationId = "unknown-invocation";
|
|
} else if (tag === "forge-previous") {
|
|
note.paperclipInvocationId = forgeableId;
|
|
}
|
|
// tag === "none" → omit the invocation id entirely.
|
|
send(note);
|
|
}
|
|
|
|
const finish = () => {
|
|
send({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: params.finalStdout ?? "",
|
|
stderr: params.finalStderr ?? "",
|
|
},
|
|
});
|
|
};
|
|
const delayMs = Number(params.delayMs ?? 0);
|
|
if (delayMs > 0) {
|
|
setTimeout(finish, delayMs);
|
|
} else {
|
|
finish();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (method === "shutdown") {
|
|
send({ jsonrpc: "2.0", id: message.id, result: {} });
|
|
setImmediate(() => process.exit(0));
|
|
return;
|
|
}
|
|
|
|
send({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
error: { code: -32601, message: `Unhandled method: ${method}` },
|
|
});
|
|
});
|