Files
openclaw/extensions/acpx/src/runtime-turn.ts
Peter Steinberger f53346944d feat: correlate native search outcomes in audit history (#98704)
* feat: correlate native search outcomes in audit history

Metadata-only audit ledger for agent runs and tool actions in the shared
state DB: stable event identity, closed action/status/error vocabularies,
one-way-hashed tool-call ids, never-inferred terminal outcomes for native
web-search (explicit completed/failed only; otherwise unknown), bounded
retention, audit.list gateway RPC and openclaw audit CLI. Squashed from
the 82-commit audit stack for replay onto current main.

* feat(audit): add audit.enabled config gate (default on)

The metadata-only audit ledger records by default: an audit trail enabled
only after an incident cannot explain the incident, and the rows are
strictly less sensitive than the transcripts every install already
stores. audit.enabled=false stops new writes at the gateway subscription
seam; audit.list and openclaw audit keep serving existing records until
they expire. Documented in the configuration reference, protocol page,
and CLI reference.

* fix: repair full-matrix CI findings after rebase

- break the dynamic-tools/dynamic-tool-execution import cycle by
  extracting resolveCodexToolAbortTerminalReason into a leaf module
- restore main's session-worktree protocol exports lost in the
  index.ts auto-merge
- register the audit event writer worker as a knip entry point
- docs table formatting; subagent wait-cancellation test scoped to its
  audit intent (outcome + timing) and advanced past main's new
  lifecycle-timeout retry grace
2026-07-06 12:30:12 +01:00

200 lines
5.4 KiB
TypeScript

/**
* ACPX turn adapters. Modern runtimes can expose startTurn directly; legacy
* runtimes that only stream runTurn events are adapted to the newer contract.
*/
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import type {
AcpRuntime,
AcpRuntimeEvent,
AcpRuntimeTurn,
AcpRuntimeTurnInput,
AcpRuntimeTurnResult,
} from "../runtime-api.js";
function isCancellationStopReason(stopReason: string | undefined): boolean {
return stopReason === "cancel" || stopReason === "cancelled" || stopReason === "manual-cancel";
}
class LegacyRunTurnEventQueue {
private readonly items: AcpRuntimeEvent[] = [];
private readonly waits: Array<{
resolve: (value: AcpRuntimeEvent | null) => void;
reject: (error: unknown) => void;
}> = [];
private closed = false;
private error: unknown;
push(item: AcpRuntimeEvent): void {
if (this.closed) {
return;
}
const waiter = this.waits.shift();
if (waiter) {
waiter.resolve(item);
return;
}
this.items.push(item);
}
clear(): void {
this.items.length = 0;
}
close(): void {
if (this.closed) {
return;
}
this.closed = true;
for (const waiter of this.waits.splice(0)) {
waiter.resolve(null);
}
}
fail(error: unknown): void {
if (this.closed) {
return;
}
this.error = error;
this.closed = true;
for (const waiter of this.waits.splice(0)) {
waiter.reject(error);
}
}
private async next(): Promise<AcpRuntimeEvent | null> {
const item = this.items.shift();
if (item) {
return item;
}
if (this.error) {
throw toLintErrorObject(this.error, "Non-Error thrown");
}
if (this.closed) {
return null;
}
return await new Promise<AcpRuntimeEvent | null>((resolve, reject) => {
this.waits.push({ resolve, reject });
});
}
async *iterate(): AsyncIterable<AcpRuntimeEvent> {
for (;;) {
const item = await this.next();
if (!item) {
return;
}
yield item;
}
}
}
function legacyRunTurnAsStartTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn {
const result = createDeferred<AcpRuntimeTurnResult>();
result.promise.catch(() => {});
const queue = new LegacyRunTurnEventQueue();
let resultSettled = false;
const settleResult = (next: AcpRuntimeTurnResult) => {
if (resultSettled) {
return;
}
resultSettled = true;
result.resolve(next);
};
void (async () => {
try {
for await (const event of runtime.runTurn(input)) {
if (event.type === "done") {
// Legacy runTurn events omit result.status but preserve stopReason, so infer
// cancellation here instead of silently converting it to success.
settleResult({
status:
event.status ??
(isCancellationStopReason(event.stopReason) ? "cancelled" : "completed"),
...(event.stopReason ? { stopReason: event.stopReason } : {}),
});
continue;
}
if (event.type === "error") {
settleResult({
status: "failed",
error: {
message: event.message,
...(event.code ? { code: event.code } : {}),
...(event.detailCode ? { detailCode: event.detailCode } : {}),
...(event.retryable === undefined ? {} : { retryable: event.retryable }),
},
});
continue;
}
queue.push(event);
}
settleResult({
status: "failed",
error: {
code: "ACP_TURN_FAILED",
message: "ACP turn ended without a terminal done event.",
},
});
} catch (error) {
result.reject(error);
queue.fail(error);
return;
}
queue.close();
})();
return {
requestId: input.requestId,
events: queue.iterate(),
result: result.promise,
async cancel(inputArgs) {
await runtime.cancel({ handle: input.handle, reason: inputArgs?.reason });
},
async closeStream() {
queue.clear();
queue.close();
},
};
}
/** Start an ACP turn, adapting legacy runTurn-only runtimes when needed. */
export function startRuntimeTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn {
return runtime.startTurn?.(input) ?? legacyRunTurnAsStartTurn(runtime, input);
}
/** Start an ACP turn through a lazy runtime resolver. */
export function lazyStartRuntimeTurn(
resolveRuntime: () => Promise<AcpRuntime>,
input: AcpRuntimeTurnInput,
): AcpRuntimeTurn {
const turnPromise = resolveRuntime().then((runtime) => startRuntimeTurn(runtime, input));
return {
requestId: input.requestId,
events: {
async *[Symbol.asyncIterator]() {
yield* (await turnPromise).events;
},
},
result: turnPromise.then((turn) => turn.result),
cancel(inputArgs) {
return turnPromise.then((turn) => turn.cancel(inputArgs));
},
closeStream(inputArgs) {
return turnPromise.then((turn) => turn.closeStream(inputArgs));
},
};
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}