Files
PaperClipAI/server/src/__tests__/duplex-observability-recorder.test.ts
T
DottaandDev Agent 25cf079ec5 feat(runner): add Codex-native application integration (#12591)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The runner package is useful only when the application can start,
observe, and recover a native Codex run safely.
> - Existing direct adapters must keep their current execution and
finalization paths.
> - The application boundary therefore needs additive persistence,
authorization, coordination, and recovery behind an explicit
experimental adapter.
> - This pull request adds that Codex-only boundary without activating
generalized providers, remote environments, or the later task/SDK
surfaces.

## Linked Issues or Issue Description

**Subsystem affected**

Shared contracts, database persistence, adapter utilities, server
native-runtime services, and the experimental Paperclip Runner adapter.

**Problem or motivation**

The already-landed runner package has a qualified Codex path, but the
application needs durable native-run state, guarded runtime selection,
authenticated coordination, tool security, finalization, and recovery
before the experimental adapter can be exercised safely.

**Proposed solution**

Add a Codex-only `paperclip_runner` application path behind the existing
default-off native-runner setting. Bind native state and coordination to
company/run identity, preserve persisted-run recovery, and leave every
direct adapter on its existing legacy execution path.

**Alternatives considered**

The earlier stack boundary introduced a generalized executor and
remote-environment lifecycle here. That made this PR depend on
implementations in higher PRs and changed reusable sandbox behavior
globally. Those pieces are now deferred together to #12592.

**Roadmap alignment**

ROADMAP.md does not list a conflicting native-runner integration
project. This change adds the application boundary for the existing
Runner architecture.

## What Changed

- Added native run/result/finalization/provider-trace persistence,
shared validators, and idempotent migration/replay coverage.
- Added guarded Codex-only runtime selection, authenticated PRP
coordination, recovery, finalization, and interaction services.
- Added run/company-bound tool-gateway authorization, credential
redaction, SSRF protections, and replay-safe behavior.
- Added the explicit `paperclip_runner` adapter behind the default-off
rollout setting.
- Preserved legacy answered-question wake projection and direct-adapter
execution/finalization paths.
- Hardened cancellation so only owned in-memory child processes are
signaled; persisted recycled PIDs/process groups are never trusted.
- Retained the narrow Claude ACPX isolated-context security follow-up
discovered after #12590.
- Deferred the generalized executor, provider ingress, remote lifecycle,
SDK/lab/eval work, release-process changes, and lockfile.

## Verification

- Changed-file delta against `master`: 133 files.
- GitHub Actions is the authoritative verification environment for this
PR.
- Full CI, security, and Greptile review will run on this lowest
unmerged stack PR.
- Local tests/build/typecheck were not run because this checkout is
resource constrained.
- Static diff/reference checks pass, and `pnpm-lock.yaml` is unchanged.

## Risks

- This touches central heartbeat and agent-route code, so legacy
compatibility is the primary risk.
- Runtime selection remains Codex-only and explicit; direct Codex,
Claude, OpenCode, process, HTTP, and plugin adapters remain on their
existing paths.
- Fresh native starts fail closed while the rollout flag is off;
persisted native records remain readable and recoverable.
- Cancellation, company/run binding, tool calls, status decisions, and
completion writes are guarded or replay-safe.

> For core feature work, check [ROADMAP.md](ROADMAP.md) first and
discuss it in #dev before opening the PR. Feature PRs that overlap with
planned core work may need to be redirected.

## Model Used

OpenAI Codex, GPT-5.6, with repository tools, code execution, and
parallel agent review.

## 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 linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [ ] I have run tests locally and they pass — GitHub Actions is
authoritative for this resource-constrained checkout
- [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 risks above
- [ ] All Paperclip CI and security gates are green
- [ ] Greptile is 5/5 with no open actionable findings
- [x] I will address all Greptile and reviewer comments before merge

## Stack

- Position: 3 of 5 overall; lowest of 3 currently unmerged
- Base: `master`
- Previous:
[#12590](https://github.com/paperclipai/paperclip/pull/12590), qualified
Claude ACPX runtime — merged
- Next: [#12592](https://github.com/paperclipai/paperclip/pull/12592),
generalized Codex executor, task experience, and developer SDKs

---------

Co-authored-by: Dev Agent <dev@paperclip.ing>
2026-08-31 14:38:38 -05:00

204 lines
6.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL,
DUPLEX_COUNTER_FALLBACK_TOTAL,
DUPLEX_COUNTER_LOSS_TOTAL,
DUPLEX_SPAN_CHANNEL_OPEN,
DUPLEX_SPAN_REQUEST,
DUPLEX_TRANSPORT_EVENT,
} from "@paperclipai/adapter-utils/duplex-observability";
import { runWithRuntimeParent } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import {
createHostDuplexObservabilityRecorder,
foldDuplexCounterMetric,
type DuplexObservabilitySpan,
type DuplexObservabilityTracer,
} from "../services/duplex-observability-recorder.js";
// A recording tracer that captures each span's name, attributes, and end time.
function createRecordingTracer(): {
tracer: DuplexObservabilityTracer;
spans: Array<{
name: string;
startTime?: number;
parentContext?: unknown;
attributes: Record<string, string | number | boolean>;
endTime?: number;
}>;
} {
const spans: Array<{
name: string;
startTime?: number;
parentContext?: unknown;
attributes: Record<string, string | number | boolean>;
endTime?: number;
}> = [];
const tracer: DuplexObservabilityTracer = {
startSpan(name, options, parentContext) {
const record = {
name,
startTime: options?.startTime,
parentContext,
attributes: {} as Record<string, string | number | boolean>,
endTime: undefined as number | undefined,
};
spans.push(record);
const span: DuplexObservabilitySpan = {
setAttribute(key, value) {
record.attributes[key] = value;
},
end(endTime) {
record.endTime = endTime;
},
};
return span;
},
};
return { tracer, spans };
}
describe("createHostDuplexObservabilityRecorder", () => {
it("records the channel-open span with only the closed dimension keys", () => {
const { tracer, spans } = createRecordingTracer();
const recorder = createHostDuplexObservabilityRecorder({
tracer,
incrementCounter: () => {},
emitTransportEvent: () => {},
now: () => 1_000,
});
recorder.recordSpan({
name: DUPLEX_SPAN_CHANNEL_OPEN,
dimensions: { provider: "daytona", transport: "duplex", outcome: "ok" },
});
expect(spans).toHaveLength(1);
expect(spans[0].name).toBe(DUPLEX_SPAN_CHANNEL_OPEN);
expect(spans[0].attributes).toEqual({
provider: "daytona",
transport: "duplex",
outcome: "ok",
});
// The channel-open span carries no latency, so it opens and ends at the same instant.
expect(spans[0].startTime).toBe(1_000);
expect(spans[0].endTime).toBe(1_000);
});
it("makes the request span duration equal the measured latency", () => {
const { tracer, spans } = createRecordingTracer();
const recorder = createHostDuplexObservabilityRecorder({
tracer,
incrementCounter: () => {},
emitTransportEvent: () => {},
now: () => 5_000,
});
recorder.recordSpan({
name: DUPLEX_SPAN_REQUEST,
dimensions: { provider: "other", transport: "duplex", outcome: "error" },
latencyMs: 250,
});
expect(spans[0].startTime).toBe(4_750);
expect(spans[0].endTime).toBe(5_000);
});
it("parents asynchronous duplex spans to the active native step", () => {
const { tracer, spans } = createRecordingTracer();
const activeParent = { traceId: "native-run" };
const recorder = createHostDuplexObservabilityRecorder({
tracer,
incrementCounter: () => {},
emitTransportEvent: () => {},
});
runWithRuntimeParent(activeParent, () => {
recorder.recordSpan({
name: DUPLEX_SPAN_CHANNEL_OPEN,
dimensions: {
provider: "daytona",
transport: "duplex",
outcome: "ok",
},
});
});
expect(spans[0].parentContext).toBe(activeParent);
});
it("folds the discriminating dimension into the counter metric name", () => {
expect(
foldDuplexCounterMetric({
metric: DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL,
dimensions: { provider: "daytona", transport: "duplex", outcome: "ok" },
}),
).toBe(DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL);
expect(
foldDuplexCounterMetric({
metric: DUPLEX_COUNTER_FALLBACK_TOTAL,
dimensions: {
provider: "daytona",
transport: "file",
outcome: "error",
fallback_reason: "gate_off",
},
}),
).toBe(`${DUPLEX_COUNTER_FALLBACK_TOTAL}.gate_off`);
expect(
foldDuplexCounterMetric({
metric: DUPLEX_COUNTER_LOSS_TOTAL,
dimensions: {
provider: "daytona",
transport: "duplex",
outcome: "error",
loss_class: "post_dispatch",
},
}),
).toBe(`${DUPLEX_COUNTER_LOSS_TOTAL}.post_dispatch`);
});
it("forwards the counter through the guarded sink with the folded metric", () => {
const metrics: string[] = [];
const recorder = createHostDuplexObservabilityRecorder({
tracer: createRecordingTracer().tracer,
incrementCounter: (metric) => metrics.push(metric),
emitTransportEvent: () => {},
});
recorder.incrementCounter({
metric: DUPLEX_COUNTER_FALLBACK_TOTAL,
dimensions: {
provider: "daytona",
transport: "file",
outcome: "error",
fallback_reason: "ready_timeout",
},
});
expect(metrics).toEqual([`${DUPLEX_COUNTER_FALLBACK_TOTAL}.ready_timeout`]);
});
it("forwards the transport event with its name and dimensions", () => {
const events: Array<{ name: string; dimensions: Record<string, unknown> }> =
[];
const recorder = createHostDuplexObservabilityRecorder({
tracer: createRecordingTracer().tracer,
incrementCounter: () => {},
emitTransportEvent: (event) => events.push(event),
});
recorder.emitEvent({
name: DUPLEX_TRANSPORT_EVENT,
dimensions: { provider: "daytona", transport: "duplex", outcome: "ok" },
});
expect(events).toEqual([
{
name: DUPLEX_TRANSPORT_EVENT,
dimensions: { provider: "daytona", transport: "duplex", outcome: "ok" },
},
]);
});
});