mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - A project can store its task workspaces with a workspace storage
provider plugin. Core calls the plugin's `workspaceOperation` method to
prepare, inspect, finalize and remove a workspace.
> - `callWorkspaceProvider` catches every rejection of that call and
throws the same conflict: "Workspace storage operation is incomplete;
retry the same workspace".
> - A provider error, an RPC timeout, a crashed worker and a worker that
never got the call all look the same in the run, the log and the API.
The message tells the user to retry, also when the failure is a fixed
configuration problem that a retry cannot fix.
> - In one diagnosis, three different configuration failures in a row
showed this same message. Finding each cause took trace span counts and
database reads.
> - This pull request lets a provider send a reason from a closed set,
and makes Core tell transport failures apart from provider failures.
> - The benefit is that a failed run says what failed and whether a
retry can help. Provider text and values still never cross into Core.
## Linked Issues or Issue Description
**What happened?**
A workspace provider's `workspaceOperation` failed. The run failed with
"Workspace storage operation is incomplete; retry the same workspace",
`errorCode: setup_failed`, and no reason in the run record or the
`heartbeat execution setup failed` log. Retrying did not help, because
the cause was a provider configuration problem.
**Expected behavior**
Core tells these cases apart: the worker did not receive the call, the
call timed out, the worker stopped during the call, or the provider
reported a failure. For a provider failure, Core shows a bounded reason.
Core tells the user to retry only when a retry can help.
**Steps to reproduce**
1. Install a workspace storage provider plugin whose
`onWorkspaceOperation` throws during `prepare` (for example, because of
missing provider settings).
2. Run an agent task in a project that uses that provider.
3. See the failed run: the error is the generic retry message, and the
run record and logs do not show the cause.
**Paperclip version / commit**
`master` at 5ac05871b5.
**Deployment mode**
Any deployment with a workspace storage provider plugin.
## What Changed
- **SDK reason contract.** New
`packages/plugins/sdk/src/workspace-operation-error.ts`:
`WORKSPACE_OPERATION_FAILURE_REASONS` (11 reasons),
`PluginWorkspaceOperationError`, `workspaceOperationErrorData` and
`readWorkspaceOperationFailureReason`. It uses the same envelope design
as `environment-task-error.ts`. The worker host attaches the envelope
only to `workspaceOperation` errors. It copies only the reason, never
the message.
- **Duck-typed reason.** The worker host also forwards an own
`workspaceFailureReason` property from any error, when the value is in
the closed set. A provider that builds against an older SDK can set the
reason before it can import the class. An inherited or unknown value is
ignored. The value can only select one of Core's fixed messages.
- **Core classification.** New
`server/src/services/workspace-provider-failure.ts`.
`callWorkspaceProvider` maps each rejection to a
`WorkspaceProviderFailureError` (409) with `kind`, `reason` and
`retryable`:
- `worker_unavailable`: no request was sent. The
`PluginWorkerUnavailableError` stays as the cause, so run setup retries
it as `plugin_worker_unavailable` (the existing bounded bootstrap
retry).
- `timeout` and `worker_exited`: the outcome is unknown, so the message
still says to retry the same workspace.
- `transport`: any other rejection. The message is the old message.
- `provider` with a reason: a fixed message per reason. Only transient
reasons say to retry.
- `provider` without a known reason: "Workspace storage provider
reported a failure; retry the same workspace".
- **Run settlement.** `failRunSetup` records
`resultJson.workspaceProviderFailure` (`kind`, `reason`, `retryable`).
The `heartbeat execution setup failed` log carries the same fields and
the run's `errorCode`. The run keeps `errorCode: setup_failed` (except
for the worker-not-running case above), so the existing setup-failure
retry, recovery and chat paths do not change.
- **Span allowlist.** The host accepts a `plugin.workspace.failure` span
and a `plugin.failure_reason` attribute. The value must be a reason from
the closed set or `unknown`.
- **Docs.** `doc/plugins/PLUGIN_RUNNER_EXECUTION.md` describes the
failure kinds and the reasons. `doc/observability.md` lists the new span
and attribute.
## Verification
- `cd packages/plugins/sdk && npx vitest run`: 21 files, 261 tests pass.
The new `tests/workspace-operation-error.test.ts` sends errors through
the real worker RPC host.
- `cd server && npx vitest run
src/__tests__/workspace-provider-failure.test.ts
src/__tests__/plugin-host-services-span.test.ts
src/services/heartbeat/run-completion.test.ts`: 3 files, 118 tests pass.
These tests cover:
- every transport kind, and that a worker refusal is still found as the
cause;
- a real worker RPC rejection with a reason, and without a reason;
- a fixed message and retry decision for every reason;
- span clamping of `plugin.failure_reason`;
- run setup for a provider reason (no retry, reason in `resultJson` and
in the log) and for a stopped worker (bounded retry).
- `cd server && npx vitest run` for the 4 plugin workspace integration
suites (`plugin-workspaces`, `plugin-fresh-workspaces`,
`project-workspace-provider`, `workspace-delivery-cleanup`): 91 tests
pass. Their lost-reply fixtures still get the old "incomplete" message.
- `npx tsc --noEmit` in `packages/plugins/sdk`: no errors. `npx tsc
--noEmit -p server/tsconfig.json` shows no errors in the changed files.
The remaining errors come from workspace packages that I did not build
locally.
- Not run locally: the full `pnpm -r typecheck`, `pnpm test:run` and
`pnpm build`. CI runs them.
## Risks
- **Behavior change for a stopped worker.** A `workspaceOperation` call
that the worker did not receive now gets `errorCode:
plugin_worker_unavailable` and the bounded bootstrap retry, as other
plugin calls already do. Before, it was a `setup_failed` run with no
retry. No request reached the provider, so the retry is safe.
- **Message changes.** Provider-reported failures and timeouts get new
fixed messages. Code that matches the exact old message only sees it for
`transport` and `remote_incomplete` failures. I did not find a match on
this message outside tests.
- **No new error codes.** Provider failures keep `setup_failed`. Retry,
recovery and chat-retry routing does not change.
- **Duck typing.** Any error with an own `workspaceFailureReason` from
the closed set is forwarded. A provider can only select one of Core's
fixed messages. It cannot send text or values.
- **Observability.** The new span attribute is a closed set and stays
inside the host allowlist. No Telemetry change.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edits, test runs). Extended thinking was on.
## 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
- [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
Related:
[paperclipai/paperclip#19326](https://github.com/paperclipai/paperclip/pull/19326)
added the same envelope design for Runner revision mismatches. I found
no duplicate PR for this change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
424 lines
18 KiB
TypeScript
424 lines
18 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js";
|
|
import type { WorkerHostCallContext } from "../../../packages/plugins/sdk/src/protocol.js";
|
|
import { SANDBOX_STARTUP_SPAN_ATTRS as A } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
|
import {
|
|
buildHostServices,
|
|
clampProviderSpanAttributes,
|
|
parseTraceparent,
|
|
recordWorkerProviderSpan,
|
|
} from "../services/plugin-host-services.js";
|
|
|
|
// Capture every span the host trust boundary hands to the real tracer.
|
|
const mockRecordSpan = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../instrumentation.js", () => ({
|
|
recordProviderPluginSpan: mockRecordSpan,
|
|
traceparentFromContextToken: () => undefined,
|
|
}));
|
|
|
|
function createEventBusStub() {
|
|
return {
|
|
forPlugin() {
|
|
return { emit: vi.fn(), subscribe: vi.fn() };
|
|
},
|
|
} as never;
|
|
}
|
|
|
|
// A well-formed W3C traceparent (the host mints it; the handler validates it).
|
|
const VALID_TRACEPARENT = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
|
|
|
|
function servicesFor() {
|
|
return buildHostServices({} as never, "plugin-record-id", "daytona", createEventBusStub());
|
|
}
|
|
|
|
function handlersFor(capabilities: readonly string[]) {
|
|
return createHostClientHandlers({
|
|
pluginId: "daytona",
|
|
capabilities: capabilities as never,
|
|
services: servicesFor(),
|
|
});
|
|
}
|
|
|
|
describe("plugin provider span host handler", () => {
|
|
beforeEach(() => {
|
|
mockRecordSpan.mockReset();
|
|
});
|
|
|
|
it("records a span with the clamped name and the allowlisted attributes", async () => {
|
|
const services = servicesFor();
|
|
await services.tracer.record(
|
|
{
|
|
name: "pack",
|
|
attributes: {
|
|
[A.provider]: "daytona",
|
|
[A.packWallMs]: 12,
|
|
},
|
|
},
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
|
|
expect(mockRecordSpan).toHaveBeenCalledTimes(1);
|
|
const call = mockRecordSpan.mock.calls[0]![0] as {
|
|
name: string;
|
|
parent: { traceId: string; spanId: string; traceFlags: number };
|
|
attributes: Record<string, unknown>;
|
|
};
|
|
expect(call.name).toBe("sandbox.daytona.pack");
|
|
expect(call.parent).toEqual({
|
|
traceId: "0af7651916cd43dd8448eb211c80319c",
|
|
spanId: "b7ad6b7169203331",
|
|
traceFlags: 1,
|
|
});
|
|
expect(call.attributes[A.provider]).toBe("daytona");
|
|
expect(call.attributes[A.packWallMs]).toBe(12);
|
|
});
|
|
|
|
it("drops every forbidden attribute before the span reaches the tracer", async () => {
|
|
const services = servicesFor();
|
|
await services.tracer.record(
|
|
{
|
|
name: "transfer",
|
|
attributes: {
|
|
[A.transferGuardCount]: 2,
|
|
// Forbidden fields that must never ride a span.
|
|
[A.execCommand]: "bash",
|
|
command: "rm -rf /",
|
|
args: "--force",
|
|
stdout: "secret output",
|
|
stderr: "secret error",
|
|
path: "/etc/passwd",
|
|
extra: "leak",
|
|
},
|
|
},
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
|
|
expect(mockRecordSpan).toHaveBeenCalledTimes(1);
|
|
const attributes = (mockRecordSpan.mock.calls[0]![0] as { attributes: Record<string, unknown> })
|
|
.attributes;
|
|
// Only the allowlisted key survives; every forbidden key is dropped.
|
|
expect(attributes).toEqual({ [A.transferGuardCount]: 2 });
|
|
for (const forbidden of [A.execCommand, "command", "args", "stdout", "stderr", "path", "extra"]) {
|
|
expect(attributes).not.toHaveProperty(forbidden);
|
|
}
|
|
});
|
|
|
|
it("drops a status message (it could carry standard-stream text)", async () => {
|
|
const services = servicesFor();
|
|
await services.tracer.record(
|
|
{ name: "transfer", status: { code: 2, message: "secret error text" } },
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
const call = mockRecordSpan.mock.calls[0]![0] as { status?: { code: number; message?: string } };
|
|
expect(call.status).toEqual({ code: 2 });
|
|
expect(call.status).not.toHaveProperty("message");
|
|
});
|
|
|
|
it("clamps an unknown span name to sandbox.daytona.other", async () => {
|
|
const services = servicesFor();
|
|
await services.tracer.record(
|
|
{ name: "rm -rf / --no-preserve-root" },
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
expect((mockRecordSpan.mock.calls[0]![0] as { name: string }).name).toBe(
|
|
"sandbox.daytona.other",
|
|
);
|
|
});
|
|
|
|
it("admits each per-round-trip span name to sandbox.daytona.<name>", async () => {
|
|
const services = servicesFor();
|
|
for (const name of [
|
|
"ensureDirectory",
|
|
"checkSymlinkEscape",
|
|
"promote",
|
|
"extractTarball",
|
|
"postUploadCommand",
|
|
]) {
|
|
await services.tracer.record(
|
|
{ name },
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
}
|
|
const recorded = mockRecordSpan.mock.calls.map((c) => (c[0] as { name: string }).name);
|
|
expect(recorded).toEqual([
|
|
"sandbox.daytona.ensureDirectory",
|
|
"sandbox.daytona.checkSymlinkEscape",
|
|
"sandbox.daytona.promote",
|
|
"sandbox.daytona.extractTarball",
|
|
"sandbox.daytona.postUploadCommand",
|
|
]);
|
|
});
|
|
|
|
it("admits the session open and close span names", async () => {
|
|
const services = servicesFor();
|
|
for (const name of ["session.open", "session.close"]) {
|
|
await services.tracer.record(
|
|
{ name },
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
}
|
|
const recorded = mockRecordSpan.mock.calls.map((c) => (c[0] as { name: string }).name);
|
|
expect(recorded).toEqual([
|
|
"sandbox.daytona.session.open",
|
|
"sandbox.daytona.session.close",
|
|
]);
|
|
});
|
|
|
|
it("forwards a valid start-time and end-time pair to the recorder", async () => {
|
|
const services = servicesFor();
|
|
const startTimeMs = Date.now() - 4500;
|
|
const endTimeMs = startTimeMs + 4500;
|
|
await services.tracer.record(
|
|
{ name: "ensureDirectory", startTimeMs, endTimeMs },
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
const call = mockRecordSpan.mock.calls[0]![0] as {
|
|
startTimeMs?: number;
|
|
endTimeMs?: number;
|
|
};
|
|
expect(call.startTimeMs).toBe(startTimeMs);
|
|
expect(call.endTimeMs).toBe(endTimeMs);
|
|
});
|
|
|
|
it("accepts a pair whose end is a small skew ahead of the host clock", async () => {
|
|
const services = servicesFor();
|
|
// The worker clock leads the host clock by a few seconds. This small skew
|
|
// is within the allowed bound, so the host keeps the native width.
|
|
const startTimeMs = Date.now() + 5000;
|
|
const endTimeMs = startTimeMs + 1000;
|
|
await services.tracer.record(
|
|
{ name: "ensureDirectory", startTimeMs, endTimeMs },
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
const call = mockRecordSpan.mock.calls[0]![0] as {
|
|
startTimeMs?: number;
|
|
endTimeMs?: number;
|
|
};
|
|
expect(call.startTimeMs).toBe(startTimeMs);
|
|
expect(call.endTimeMs).toBe(endTimeMs);
|
|
});
|
|
|
|
it("drops an invalid timestamp pair so the synchronous path runs", async () => {
|
|
const services = servicesFor();
|
|
const now = Date.now();
|
|
const invalidPairs: Array<{ startTimeMs?: unknown; endTimeMs?: unknown; why: string }> = [
|
|
{ startTimeMs: now, endTimeMs: now - 1000, why: "reversed order" },
|
|
{ startTimeMs: Number.NaN, endTimeMs: now, why: "non-finite start" },
|
|
{ startTimeMs: now, endTimeMs: Number.POSITIVE_INFINITY, why: "non-finite end" },
|
|
{ startTimeMs: now, endTimeMs: now + 11 * 60 * 1000, why: "over-ceiling duration" },
|
|
{ startTimeMs: now - 2 * 60 * 60 * 1000, endTimeMs: now - 2 * 60 * 60 * 1000 + 10, why: "over-age start" },
|
|
{ startTimeMs: now, endTimeMs: now + 2 * 60 * 1000, why: "end far in the future" },
|
|
{ startTimeMs: now + 5 * 60 * 1000, endTimeMs: now + 5 * 60 * 1000 + 10, why: "start and end in the future" },
|
|
];
|
|
for (const pair of invalidPairs) {
|
|
mockRecordSpan.mockReset();
|
|
await services.tracer.record(
|
|
{ name: "ensureDirectory", startTimeMs: pair.startTimeMs, endTimeMs: pair.endTimeMs } as never,
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
// The span still records (the synchronous path), but without a timestamp.
|
|
const call = mockRecordSpan.mock.calls[0]![0] as {
|
|
startTimeMs?: number;
|
|
endTimeMs?: number;
|
|
};
|
|
expect(call.startTimeMs, pair.why).toBeUndefined();
|
|
expect(call.endTimeMs, pair.why).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("records the synchronous path when the timestamp pair is absent", async () => {
|
|
const services = servicesFor();
|
|
await services.tracer.record(
|
|
{ name: "pack" },
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
const call = mockRecordSpan.mock.calls[0]![0] as {
|
|
startTimeMs?: number;
|
|
endTimeMs?: number;
|
|
};
|
|
expect(call.startTimeMs).toBeUndefined();
|
|
expect(call.endTimeMs).toBeUndefined();
|
|
});
|
|
|
|
it("rejects a malformed traceparent — no span is recorded", async () => {
|
|
const services = servicesFor();
|
|
for (const bad of [
|
|
undefined,
|
|
"not-a-traceparent",
|
|
"00-xyz-b7ad6b7169203331-01",
|
|
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331", // missing flags
|
|
"00-00000000000000000000000000000000-b7ad6b7169203331-01", // all-zero trace id
|
|
]) {
|
|
await services.tracer.record(
|
|
{ name: "pack" },
|
|
{ traceparent: bad } as WorkerHostCallContext,
|
|
);
|
|
}
|
|
expect(mockRecordSpan).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a span from a plugin that lacks the environment-driver capability", async () => {
|
|
const handlers = handlersFor([]);
|
|
await expect(
|
|
handlers["span.record"](
|
|
{ name: "pack" },
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
),
|
|
).rejects.toThrow(/capabilit/i);
|
|
expect(mockRecordSpan).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("admits a span from a plugin that holds the environment-driver capability", async () => {
|
|
const handlers = handlersFor(["environment.drivers.register"]);
|
|
await handlers["span.record"](
|
|
{ name: "pack", attributes: { [A.provider]: "daytona" } },
|
|
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
|
);
|
|
expect(mockRecordSpan).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("parseTraceparent", () => {
|
|
it("accepts a well-formed traceparent and returns the parts", () => {
|
|
expect(parseTraceparent(VALID_TRACEPARENT)).toEqual({
|
|
traceId: "0af7651916cd43dd8448eb211c80319c",
|
|
spanId: "b7ad6b7169203331",
|
|
traceFlags: 1,
|
|
});
|
|
});
|
|
|
|
it("rejects malformed, all-zero, and forbidden-version traceparents", () => {
|
|
expect(parseTraceparent(undefined)).toBeNull();
|
|
expect(parseTraceparent("garbage")).toBeNull();
|
|
expect(parseTraceparent("00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01")).toBeNull();
|
|
expect(parseTraceparent("ff-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("clampProviderSpanAttributes", () => {
|
|
it("keeps only allowlisted keys and normalizes the provider family", () => {
|
|
expect(
|
|
clampProviderSpanAttributes({
|
|
[A.provider]: "some-operator-key",
|
|
[A.packWallMs]: 7,
|
|
[A.transferWallMs]: Number.NaN,
|
|
[A.execCommand]: "bash",
|
|
}),
|
|
).toEqual({
|
|
// An unknown provider key maps to `plugin`, never the raw key.
|
|
[A.provider]: "plugin",
|
|
[A.packWallMs]: 7,
|
|
// A non-finite number yields no attribute; `exec.command` is not allowed.
|
|
});
|
|
});
|
|
|
|
it("keeps the transfer direction for each closed-set value", () => {
|
|
expect(clampProviderSpanAttributes({ [A.transferDirection]: "inbound" })).toEqual({
|
|
[A.transferDirection]: "inbound",
|
|
});
|
|
expect(clampProviderSpanAttributes({ [A.transferDirection]: "outbound" })).toEqual({
|
|
[A.transferDirection]: "outbound",
|
|
});
|
|
});
|
|
|
|
it("drops a transfer direction outside the closed set or of the wrong type", () => {
|
|
// A free-form string, an empty string, and a non-string all yield no
|
|
// attribute, so the direction stays bounded and low-cardinality.
|
|
expect(clampProviderSpanAttributes({ [A.transferDirection]: "sideways" })).toEqual({});
|
|
expect(clampProviderSpanAttributes({ [A.transferDirection]: "" })).toEqual({});
|
|
expect(clampProviderSpanAttributes({ [A.transferDirection]: 1 })).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("nested worker timing receipts",()=>{
|
|
it("keeps lease verification beneath its worker and clamps provider state", () => {
|
|
mockRecordSpan.mockReset();
|
|
const rootParent = { traceId: "0af7651916cd43dd8448eb211c80319c", spanId: "1111111111111111", traceFlags: 1 };
|
|
const verifyParent = { ...rootParent, spanId: "2222222222222222" };
|
|
mockRecordSpan.mockReturnValueOnce(rootParent).mockReturnValueOnce(verifyParent);
|
|
const now = Date.now();
|
|
recordWorkerProviderSpan({ name: "plugin.worker", startTimeMs: now - 100, endTimeMs: now, children: [
|
|
{ name: "lease.verify", startTimeMs: now - 90, endTimeMs: now - 10, children: [
|
|
{ name: "lease.sentinel", startTimeMs: now - 80, endTimeMs: now - 20,
|
|
attributes: { "paperclip.sandbox.startup.state": "archived", token: "private-token" } },
|
|
] },
|
|
] }, { traceparent: VALID_TRACEPARENT });
|
|
expect(mockRecordSpan.mock.calls[1][0]).toMatchObject({ name: "sandbox.daytona.lease.verify", parent: rootParent });
|
|
expect(mockRecordSpan.mock.calls[2][0]).toMatchObject({ name: "sandbox.daytona.lease.sentinel", parent: verifyParent,
|
|
startTimeMs: now - 80, endTimeMs: now - 20, attributes: { "paperclip.sandbox.startup.state": "archived" } });
|
|
expect(JSON.stringify(mockRecordSpan.mock.calls)).not.toContain("private-token");
|
|
expect(clampProviderSpanAttributes({ "paperclip.sandbox.startup.state": "private-state" })).toEqual({});
|
|
});
|
|
|
|
it("admits a workspace failure span with only a closed failure reason", () => {
|
|
mockRecordSpan.mockReset();
|
|
const now = Date.now();
|
|
recordWorkerProviderSpan({ name: "plugin.workspace.failure", startTimeMs: now - 1, endTimeMs: now, status: { code: 2 },
|
|
attributes: { "plugin.failure_reason": "storage_grant_unavailable", message: "Project mount grants unavailable" } }, { traceparent: VALID_TRACEPARENT });
|
|
expect(mockRecordSpan.mock.calls[0][0]).toMatchObject({ name: "plugin.workspace.failure", attributes: { "plugin.failure_reason": "storage_grant_unavailable" } });
|
|
expect(JSON.stringify(mockRecordSpan.mock.calls)).not.toContain("mount grants");
|
|
expect(clampProviderSpanAttributes({ "plugin.failure_reason": "unknown" })).toEqual({ "plugin.failure_reason": "unknown" });
|
|
expect(clampProviderSpanAttributes({ "plugin.failure_reason": "token=private" })).toEqual({});
|
|
expect(clampProviderSpanAttributes({ "plugin.failure_reason": 1 })).toEqual({});
|
|
});
|
|
|
|
it("admits every closed lease phase and rejects dynamic phase names", () => {
|
|
mockRecordSpan.mockReset();
|
|
const names = ["lease.wait_for_teardown", "lease.lookup", "lease.refresh", "lease.channel_cleanup",
|
|
"lease.start", "lease.recover", "lease.verify", "lease.workspace", "lease.sentinel", "lease.shell",
|
|
"lease.create", "lease.expiry", "lease.cleanup", "lease.private-resource-id"];
|
|
for (const name of names) recordWorkerProviderSpan({ name }, { traceparent: VALID_TRACEPARENT });
|
|
expect(mockRecordSpan.mock.calls.map(call => call[0].name)).toEqual([
|
|
...names.slice(0, -1).map(name => `sandbox.daytona.${name}`), "sandbox.daytona.other",
|
|
]);
|
|
});
|
|
|
|
it("uses host-generated parents, clamps content and bounds child clocks",()=>{
|
|
mockRecordSpan.mockReset();
|
|
const childParent={traceId:"0af7651916cd43dd8448eb211c80319c",spanId:"1111111111111111",traceFlags:1};
|
|
mockRecordSpan.mockReturnValue(childParent);
|
|
const now=Date.now();
|
|
recordWorkerProviderSpan({name:"plugin.worker",startTimeMs:now-100,endTimeMs:now,children:[
|
|
{name:"plugin.remote.operation",startTimeMs:now-90,endTimeMs:now-10,attributes:{"plugin.operation":"script.settings",secret:"private"}},
|
|
{name:"plugin.runtime.verify",startTimeMs:now-200,endTimeMs:now},
|
|
]},{traceparent:VALID_TRACEPARENT} as WorkerHostCallContext);
|
|
expect(mockRecordSpan).toHaveBeenCalledTimes(2);
|
|
expect(mockRecordSpan.mock.calls[1][0]).toMatchObject({parent:childParent,attributes:{"plugin.operation":"script.settings"}});
|
|
expect(JSON.stringify(mockRecordSpan.mock.calls)).not.toContain("private");
|
|
});
|
|
});
|
|
|
|
it("allows a workspace provider timing receipt without environment-driver authority",async()=>{
|
|
mockRecordSpan.mockReset();
|
|
await handlersFor(["workspaces.manage"])["span.record"]({name:"plugin.worker"},{traceparent:VALID_TRACEPARENT} as WorkerHostCallContext);
|
|
expect(mockRecordSpan).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("retains short children of a multi-hour worker invocation and shares its aggregate budget",()=>{
|
|
mockRecordSpan.mockReset();mockRecordSpan.mockReturnValue({traceId:"0af7651916cd43dd8448eb211c80319c",spanId:"1111111111111111",traceFlags:1});
|
|
const now=Date.now(),context={traceparent:VALID_TRACEPARENT,providerSpanBudget:{remaining:3}};
|
|
recordWorkerProviderSpan({name:"plugin.worker",startTimeMs:now-7200000,endTimeMs:now,children:[
|
|
{name:"pack",startTimeMs:now-7199990,endTimeMs:now-7199980}
|
|
]},context);
|
|
recordWorkerProviderSpan({name:"pack"},context);recordWorkerProviderSpan({name:"pack"},context);
|
|
expect(mockRecordSpan).toHaveBeenCalledTimes(3);
|
|
expect(mockRecordSpan.mock.calls[1][0]).toMatchObject({name:"sandbox.daytona.pack",startTimeMs:now-7199990});
|
|
});
|
|
|
|
|
|
it("links bounded earlier remote lifecycles to their host-owned observer",()=>{
|
|
mockRecordSpan.mockReset();mockRecordSpan.mockReturnValue({traceId:"0af7651916cd43dd8448eb211c80319c",spanId:"1111111111111111",traceFlags:1});
|
|
const now=Date.now();
|
|
recordWorkerProviderSpan({name:"plugin.worker",startTimeMs:now-10,endTimeMs:now,children:[
|
|
{name:"plugin.remote.lifecycle",relationship:"linked",startTimeMs:now-1000,endTimeMs:now-100,children:[
|
|
{name:"plugin.remote.operation",startTimeMs:now-900,endTimeMs:now-200}
|
|
]},
|
|
{name:"plugin.remote.operation",relationship:"linked",startTimeMs:now-1000,endTimeMs:now-100},
|
|
{name:"plugin.remote.lifecycle",relationship:"linked",startTimeMs:now-700000,endTimeMs:now-100},
|
|
]},{traceparent:VALID_TRACEPARENT});
|
|
expect(mockRecordSpan).toHaveBeenCalledTimes(3);
|
|
expect(mockRecordSpan.mock.calls[1][0]).toMatchObject({name:"plugin.remote.lifecycle",linked:true,startTimeMs:now-1000});
|
|
expect(mockRecordSpan.mock.calls[2][0].linked).toBeUndefined();
|
|
});
|