feat: integrate governed run output spill (#1100)

This commit is contained in:
Brad Groux 2026-07-25 23:52:40 -05:00 committed by GitHub
parent e3fd3163df
commit 0876dea7eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 472 additions and 22 deletions

View file

@ -2659,6 +2659,31 @@ attempt logs. Clients should persist `nextCursor` and reconnect with it rather
than inferring order from timestamps. `sessionId`, `turnId`, and `itemId` are
separate optional identities and appear only when the provider reports them.
Run payloads above the inline event limit are redacted before being persisted
as `run-output-artifact/v1`. The event keeps a `run-output-preview/v1` under
`payload.outputArtifact` with the opaque artifact ID, media/content class,
original and preview byte counts, truncation reason, state, and allowed query
operations. No response exposes the host storage path.
Authorized readers query the artifact within the exact workspace, task, run,
and attempt scope:
```http
GET /api/agents/TASK-001/output-artifacts/spill_abc123...?runId=attempt_123&attemptId=attempt_123
GET /api/agents/TASK-001/output-artifacts/spill_abc123...?runId=attempt_123&attemptId=attempt_123&operation=byte-range&offset=0&length=4096
GET /api/agents/TASK-001/output-artifacts/spill_abc123...?runId=attempt_123&attemptId=attempt_123&operation=line-range&startLine=100&lineCount=50
GET /api/agents/TASK-001/output-artifacts/spill_abc123...?runId=attempt_123&attemptId=attempt_123&operation=json-path&jsonPath=$.rows[0]
GET /api/agents/TASK-001/output-artifacts/spill_abc123.../download?runId=attempt_123&attemptId=attempt_123&offset=0&length=4194304
```
Metadata reads remain available after expiry or quarantine so event references
do not break silently. Content queries are bounded and rate-limited. Download
ranges revalidate the stored hash and secret shapes first; a mismatch
quarantines the body. Invalid UTF-8, binary, and compressed content is
metadata-only unless an explicit storage policy allows it. Cleanup honors
active-run leases, removes eligible bodies deterministically, retains
tombstones, and reports reclaimed bytes.
Stop and message requests must carry the `attemptId` returned by status so a
delayed control cannot affect a replacement run:

View file

@ -298,6 +298,7 @@ First-class support for autonomous coding agents.
- **Agent output stream** — Real-time agent output via WebSocket with auto-scroll and clear
- **Causal run-event journal** — OpenClaw, Codex CLI, Codex SDK, Codex app-server, Claude Code, ACP stdio, and Hermes map provider output into one bounded, redacted, append-only `run-event/v1` stream with per-attempt ordering, provider deduplication, REST cursor replay, gap-free WebSocket reconnect, and compatible legacy output projections
- **Provider-neutral progress watchdog** — A versioned, bounded evaluator detects identical tool, error, and assistant-tail repetition, short multi-step cycles, repeated failed edits, and sustained time or spend without durable progress from the shared run journal. Policy controls confidence escalation, progress signals, allowed repetition leases, and recovery posture. The restart-safe server coordinator journals attributed findings and action outcomes, rehydrates per-turn and per-run recovery use, uses verified provider-native steering, and stops the exact attempt for configured pause or cancel. Retry and fallback stay behind the governed recovery planner. Permission-gated APIs expose durable findings and actor-attributed acknowledge, continue, or cancel overrides
- **Governed oversized-output spill** — Tool, command, MCP, provider, and other oversized run payloads use one provider-neutral policy: the complete redacted body is stored behind an opaque workspace/run-scoped artifact ID while the event carries a bounded preview, integrity hash, retention state, and safe query hints. Text/JSON support byte, line, and bounded JSON-path queries; binary, invalid UTF-8, and compressed bodies quarantine by default
- **Provider-neutral runtime hooks** — Trusted in-process features can register bounded `runtime-hook/v1` pre-dispatch decisions and passive post-event observations with deterministic scope ordering, timeouts, reentrancy protection, dry-run, and causal evidence; arbitrary executable and HTTP handlers remain unsupported
- **Provider-native approval broker** — Provider requests pause on an exact action hash, persist a bounded workspace-scoped review record, and resume only after an authenticated compare-and-set approve/reject decision; expiry, interruption, cancellation, stale evidence, changed arguments, and duplicate decisions fail closed
- **Run-scoped tool control plane** — Versioned MCP definitions and discovery,

View file

@ -14,6 +14,9 @@ import { SqliteRunEventRepository } from '../storage/sqlite/run-event-repository
import { SqliteDatabase } from '../storage/sqlite/database.js';
import { SQLITE_BASE_MIGRATIONS } from '../storage/sqlite/migrations.js';
import { RunEventJournalService } from '../services/run-event-journal-service.js';
import { RunOutputArtifactService } from '../services/run-output-artifact-service.js';
import { RunOutputSpillService } from '../services/run-output-spill-service.js';
import { FileRunOutputArtifactRepository } from '../storage/run-output-artifact-repository.js';
import { getProviderRunEventMapper } from '../services/provider-run-event-mappers.js';
import { RunEventEnvelopeSchema, RunEventKindSchema } from '../schemas/run-event-schemas.js';
@ -129,25 +132,121 @@ describe('RunEventJournalService', () => {
});
});
it('drops payload bodies that remain oversized after bounded normalization', async () => {
it('spills oversized payloads once and journals a bounded governed reference', async () => {
const directory = await temporaryDirectory();
const journal = new RunEventJournalService(new FileRunEventRepository(directory));
const result = await journal.append(
appendInput({
payload: {
chunks: Array.from(
{ length: 10 },
(_, index) => `${index}:${'bounded provider output '.repeat(360)}`
),
},
const artifactDirectory = await temporaryDirectory();
const artifacts = new FileRunOutputArtifactRepository(artifactDirectory);
const artifactService = new RunOutputArtifactService(
artifacts,
new RunOutputSpillService({
schemaVersion: 'run-output-spill-policy/v1',
inlineBytes: 8 * 1024,
maxQueryBytes: 32 * 1024,
maxJsonDepth: 12,
retentionSeconds: 3_600,
activeLeaseSeconds: 0,
allowBinaryPersistence: false,
allowCompressedPersistence: false,
})
);
const journal = new RunEventJournalService(
new FileRunEventRepository(directory),
artifactService
);
const oversized = appendInput({
providerEventId: 'provider_oversized_1',
payload: {
chunks: Array.from(
{ length: 10 },
(_, index) => `${index}:${'bounded provider output '.repeat(360)}`
),
},
});
const result = await journal.append(oversized);
const duplicate = await journal.append(oversized);
expect(result.event.redaction.status).toBe('dropped');
expect(result.event.payload).toMatchObject({ dropped: true });
expect(duplicate).toEqual({ event: result.event, appended: false });
expect(result.event.payload).toMatchObject({
spilled: true,
originalPayloadBytes: expect.any(Number),
});
expect(result.event.payload.outputArtifact).toMatchObject({
schemaVersion: 'run-output-preview/v1',
inline: false,
truncated: true,
truncationReason: 'event-limit',
artifact: {
state: 'available',
},
});
expect(result.event.redaction.persistedBytes).toBeLessThan(32 * 1024);
const persisted = await artifacts.list({ workspaceId: 'local' });
expect(persisted).toHaveLength(1);
expect(persisted[0]).toMatchObject({
scope: {
taskId: 'task_1',
runId: 'attempt_1',
attemptId: 'attempt_1',
},
source: {
kind: 'run-event',
eventId: result.event.eventId,
},
truncationReason: 'event-limit',
});
});
it.each(['codex-cli', 'claude-code', 'openclaw'] as const)(
'uses the same spill preview contract for %s',
async (provider) => {
const directory = await temporaryDirectory();
const artifactDirectory = await temporaryDirectory();
const artifacts = new FileRunOutputArtifactRepository(artifactDirectory);
const journal = new RunEventJournalService(
new FileRunEventRepository(directory),
new RunOutputArtifactService(
artifacts,
new RunOutputSpillService({
schemaVersion: 'run-output-spill-policy/v1',
inlineBytes: 8 * 1024,
maxQueryBytes: 32 * 1024,
maxJsonDepth: 12,
retentionSeconds: 3_600,
activeLeaseSeconds: 0,
allowBinaryPersistence: false,
allowCompressedPersistence: false,
})
)
);
const result = await journal.append(
appendInput({
providerEventId: `${provider}_oversized_1`,
source: { provider, adapter: provider, agent: provider },
payload: { content: 'provider-neutral output line\n'.repeat(400) },
})
);
expect(result.event.payload.outputArtifact).toMatchObject({
schemaVersion: 'run-output-preview/v1',
inline: false,
mediaType: 'application/json',
contentClass: 'json',
truncated: true,
truncationReason: 'event-limit',
artifact: {
state: 'available',
operations: ['metadata', 'byte-range', 'line-range', 'json-path', 'download'],
},
queryHints: {
maxResultBytes: 32 * 1024,
maxJsonDepth: 12,
},
});
expect(await artifacts.list({ workspaceId: 'local' })).toHaveLength(1);
}
);
it.runIf(process.platform !== 'win32')('refuses a symlinked journal target', async () => {
const directory = await temporaryDirectory();
const taskDirectory = path.join(directory, 'task_1');

View file

@ -22,7 +22,11 @@ import {
TASK_VERIFICATION_STATUSES,
} from '@veritas-kanban/shared';
import { asyncHandler } from '../middleware/async-handler.js';
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
import {
ConflictError,
NotFoundError,
ValidationError,
} from '../middleware/error-handler.js';
import { requireLocalAgentCapability } from '../middleware/local-agent-capability.js';
import { AgentBudgetPolicySchema } from '../schemas/agent-budget-schemas.js';
import {
@ -33,6 +37,11 @@ import {
import { ProviderRuntimeCapabilityIdSchema } from '../schemas/provider-runtime-manifest-schemas.js';
import { TaskCommitPolicySchema } from '../schemas/task-envelope-schemas.js';
import { RunEventQuerySchema } from '../schemas/run-event-schemas.js';
import {
RunOutputArtifactDownloadQuerySchema,
RunOutputArtifactHttpQuerySchema,
} from '../schemas/run-output-artifact-schemas.js';
import { RunOutputArtifactService } from '../services/run-output-artifact-service.js';
import {
workspaceExecutionTrustDecisionInputSchema,
workspaceExecutionTrustRevokeInputSchema,
@ -53,6 +62,12 @@ import { ProgressWatchdogControlService } from '../services/progress-watchdog-co
const router: RouterType = Router();
const workspaceExecutionTrust = getWorkspaceExecutionTrustService();
let progressWatchdogControl: ProgressWatchdogControlService | undefined;
let runOutputArtifactService: RunOutputArtifactService | undefined;
function getRunOutputArtifactService(): RunOutputArtifactService {
runOutputArtifactService ??= new RunOutputArtifactService();
return runOutputArtifactService;
}
// Validation schemas
const AgentTypeSchema = z.string().min(1).max(50);
@ -463,6 +478,89 @@ router.get(
})
);
// GET /api/agents/:taskId/output-artifacts/:artifactId - Query governed run output.
router.get(
'/:taskId/output-artifacts/:artifactId',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const input = RunOutputArtifactHttpQuerySchema.parse({
...req.query,
operation: req.query.operation ?? 'metadata',
});
const lookup = {
workspaceId: req.auth?.workspaceId || 'local',
taskId: req.params.taskId as string,
runId: input.runId,
attemptId: input.attemptId,
turnId: input.turnId,
artifactId: req.params.artifactId as string,
};
const query =
input.operation === 'json-path'
? { operation: input.operation, path: input.jsonPath }
: input.operation === 'metadata'
? { operation: input.operation }
: input.operation === 'byte-range'
? { operation: input.operation, offset: input.offset, length: input.length }
: {
operation: input.operation,
startLine: input.startLine,
lineCount: input.lineCount,
};
const result = await getRunOutputArtifactService().query({
lookup,
query,
requesterId: requestActor(req),
});
if (!result) throw new NotFoundError('Run output artifact not found.');
res.json(result);
})
);
// GET /api/agents/:taskId/output-artifacts/:artifactId/download - Stream one bounded range.
router.get(
'/:taskId/output-artifacts/:artifactId/download',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const input = RunOutputArtifactDownloadQuerySchema.parse(req.query);
const lookup = {
workspaceId: req.auth?.workspaceId || 'local',
taskId: req.params.taskId as string,
runId: input.runId,
attemptId: input.attemptId,
turnId: input.turnId,
artifactId: req.params.artifactId as string,
};
const metadata = await getRunOutputArtifactService().query({
lookup,
query: { operation: 'metadata' },
requesterId: requestActor(req),
});
if (!metadata) throw new NotFoundError('Run output artifact not found.');
if (metadata.metadata.state !== 'available') {
throw new ConflictError(`Run output artifact is ${metadata.metadata.state}.`);
}
const range = await getRunOutputArtifactService().readDownloadRange(
lookup,
requestActor(req),
input.offset,
input.length
);
if (!range) throw new ConflictError('Run output artifact body is unavailable.');
res.status(206);
res.setHeader('Content-Type', range.metadata.mediaType);
res.setHeader(
'Content-Disposition',
`attachment; filename="${range.metadata.id}.bin"`
);
res.setHeader(
'Content-Range',
`bytes ${range.offset}-${Math.max(range.offset, range.offset + range.length - 1)}/${range.metadata.storedBytes}`
);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Length', String(range.length));
res.end(Buffer.from(range.content));
})
);
router.post(
'/:taskId/phase/transitions',
asyncHandler(async (req: AuthenticatedRequest, res) => {

View file

@ -108,3 +108,36 @@ export const RunOutputPreviewSchema: z.ZodType<RunOutputPreview> = z
.optional(),
})
.strict();
const RunOutputArtifactScopeQuerySchema = z
.object({
runId: IdentifierSchema,
attemptId: IdentifierSchema,
turnId: IdentifierSchema.optional(),
})
.strict();
export const RunOutputArtifactHttpQuerySchema = z.discriminatedUnion('operation', [
RunOutputArtifactScopeQuerySchema.extend({
operation: z.literal('metadata'),
}),
RunOutputArtifactScopeQuerySchema.extend({
operation: z.literal('byte-range'),
offset: z.coerce.number().int().nonnegative(),
length: z.coerce.number().int().min(1).max(4 * 1024 * 1024),
}),
RunOutputArtifactScopeQuerySchema.extend({
operation: z.literal('line-range'),
startLine: z.coerce.number().int().positive(),
lineCount: z.coerce.number().int().min(1).max(1_000),
}),
RunOutputArtifactScopeQuerySchema.extend({
operation: z.literal('json-path'),
jsonPath: z.string().trim().min(1).max(2_000),
}),
]);
export const RunOutputArtifactDownloadQuerySchema = RunOutputArtifactScopeQuerySchema.extend({
offset: z.coerce.number().int().nonnegative().default(0),
length: z.coerce.number().int().min(1).max(4 * 1024 * 1024).default(4 * 1024 * 1024),
}).strict();

View file

@ -8384,6 +8384,7 @@ export class ClawdbotAgentService {
const pending = pendingAgents.get(taskId);
const provider = options.provider ?? pending?.provider ?? 'system';
const result = await this.runEvents.append({
workspaceId: pending?.taskEnvelope?.workspace.workspaceId ?? 'local',
taskId,
attemptId,
kind,
@ -8433,11 +8434,19 @@ export class ClawdbotAgentService {
private emitJournalOutput(event: RunEventEnvelope): void {
const pending = pendingAgents.get(event.taskId);
if (!pending || pending.attemptId !== event.attemptId) return;
const outputArtifact =
event.payload.outputArtifact &&
typeof event.payload.outputArtifact === 'object' &&
!Array.isArray(event.payload.outputArtifact)
? event.payload.outputArtifact
: undefined;
const content =
typeof event.payload.content === 'string'
? event.payload.content
: typeof event.payload.summary === 'string'
? event.payload.summary
: outputArtifact && typeof outputArtifact.content === 'string'
? outputArtifact.content
: undefined;
if (!content?.trim()) return;
const type: AgentOutput['type'] =

View file

@ -6,6 +6,7 @@ export type DataLifecycleClassId =
| 'workProducts'
| 'telemetry'
| 'workflowRuns'
| 'runOutputArtifacts'
| 'notifications'
| 'chat'
| 'audit'
@ -220,6 +221,33 @@ export const DATA_LIFECYCLE_POLICIES: readonly DataLifecyclePolicy[] = [
previewSafety:
'Show status, workflow, task link, run age, snapshot count, and active/blocked state.',
},
{
id: 'runOutputArtifacts',
label: 'Governed run output artifacts',
description:
'Redacted oversized tool, command, MCP, provider, and run-event bodies plus scoped metadata tombstones.',
tables: ['run_output_artifacts'],
defaultRetention:
'Bodies expire after the configured spill retention window; active-run leases defer cleanup and metadata tombstones remain.',
userControls: ['Query or download bounded ranges when workspace and run permissions allow.'],
adminControls: [
'Review storage usage, retention, quarantine state, cleanup results, and workspace export/delete posture.',
],
exportBehavior:
'Workspace exports include scoped metadata; body export requires explicit authorized retrieval and export-time integrity/secret validation.',
deleteBehavior:
'Retention cleanup removes eligible bodies but preserves metadata tombstones so causal event references remain explainable.',
auditBehavior:
'Creation, quarantine reason, expiry state, validation time, and reclaimed bytes remain attributable in artifact metadata and cleanup results.',
redaction:
'Bodies are redacted before persistence and excluded from support bundles; support data includes bounded metadata only.',
containsSecrets: false,
containsPrivatePaths: true,
containsGeneratedContent: true,
workspaceScoped: true,
previewSafety:
'Show opaque ID, causal scope, source kind, sizes, hash, redaction state, retention, and quarantine reason without body content or host paths.',
},
{
id: 'notifications',
label: 'Notifications and subscriptions',

View file

@ -111,6 +111,7 @@ export class MaintenanceService {
const generatedAt = new Date().toISOString();
const sqlite = getSqliteStorageDiagnostics();
const workProducts = await getWorkProductService().maintenancePreview();
const runOutputArtifacts = await this.collectRunOutputArtifactStats(generatedAt);
const [
storageRoot,
runtimeDir,
@ -224,6 +225,16 @@ export class MaintenanceService {
)
),
},
{
id: 'run-output-artifacts',
label: 'Governed run output artifacts',
bytes: runOutputArtifacts.bytes,
itemCount: runOutputArtifacts.itemCount,
cleanupEligibleCount: runOutputArtifacts.cleanupEligibleCount,
retainedReason:
'Active-run leases retain bodies; expired and quarantined metadata remains as causal tombstones.',
lastUsedAt: runOutputArtifacts.lastUsedAt,
},
];
return {
@ -241,6 +252,7 @@ export class MaintenanceService {
tableCounts: {
work_products: workProducts.totals.products,
work_product_versions: workProducts.totals.versions,
run_output_artifacts: runOutputArtifacts.itemCount,
},
}),
cleanupPreview: {
@ -319,12 +331,14 @@ export class MaintenanceService {
'raw prompts',
'raw chat content',
'generated sensitive text',
'raw run output artifact bodies',
],
redactionRules: [
'Bearer tokens, API keys, JWTs, opaque tokens, and long hashes are replaced.',
'Local home, project, storage, runtime, and log paths are replaced with redacted path labels.',
'Log files are included as redacted tails only, capped at 200 lines per source.',
'Admission queue diagnostics use the bounded inspection projection and never include durable replay targets.',
'Run output artifact bodies are excluded; lifecycle summaries contain metadata and policy only.',
],
files: summary.logs.map((source) => this.redactLogSource(source)),
};
@ -377,6 +391,37 @@ export class MaintenanceService {
);
}
private async collectRunOutputArtifactStats(now: string): Promise<{
bytes: number;
itemCount: number;
cleanupEligibleCount: number;
lastUsedAt?: string;
}> {
try {
const artifacts = await getStorage().runOutputArtifacts.list({
workspaceId: 'local',
limit: 2_000,
});
const current = Date.parse(now);
const available = artifacts.filter((artifact) => artifact.state === 'available');
return {
bytes: available.reduce((total, artifact) => total + artifact.storedBytes, 0),
itemCount: artifacts.length,
cleanupEligibleCount: available.filter(
(artifact) =>
Date.parse(artifact.retention.expiresAt) <= current &&
(!artifact.retention.activeLeaseUntil ||
Date.parse(artifact.retention.activeLeaseUntil) <= current)
).length,
lastUsedAt: this.latestDate(
artifacts.map((artifact) => artifact.redaction.validatedAt)
),
};
} catch {
return { bytes: 0, itemCount: 0, cleanupEligibleCount: 0 };
}
}
private async buildHealthChecks(checkedAt: string): Promise<MaintenanceHealthCheck[]> {
const [storageWritable, diskState, logsState, workProductsState] = await Promise.all([
this.checkStorageWritable(),

View file

@ -6,8 +6,10 @@ import {
type RunEventAppendResult,
type RunEventEnvelope,
type RunEventJsonValue,
type RunEventKind,
type RunEventPage,
type RunEventQuery,
type RunOutputSourceKind,
} from '@veritas-kanban/shared';
import type { RunEventRepository } from '../storage/interfaces.js';
import { FileRunEventRepository } from '../storage/run-event-repository.js';
@ -16,6 +18,7 @@ import { RunEventEnvelopeSchema } from '../schemas/run-event-schemas.js';
import { redactString } from '../lib/redact.js';
import { createLogger } from '../lib/logger.js';
import { validatePathSegment } from '../utils/sanitize.js';
import { RunOutputArtifactService } from './run-output-artifact-service.js';
const MAX_PAYLOAD_BYTES = 32 * 1024;
const MAX_STRING_BYTES = 8 * 1024;
@ -166,6 +169,46 @@ function sanitizePayload(payload: Record<string, unknown>): {
};
}
function serializeForSpill(payload: Record<string, unknown>): string | undefined {
try {
const serialized = JSON.stringify(payload);
return typeof serialized === 'string' ? serialized : undefined;
} catch {
return undefined;
}
}
function spillSourceKind(kind: RunEventKind): RunOutputSourceKind {
if (kind === 'tool.completed') return 'tool-result';
if (kind.startsWith('command.') || kind.startsWith('stream.')) return 'command-output';
if (kind.startsWith('mcp.')) return 'mcp-result';
if (kind.startsWith('provider.') || kind.startsWith('message.')) return 'provider-payload';
return 'run-event';
}
function spillSourceName(payload: Record<string, unknown>): string | undefined {
const candidate =
typeof payload.toolName === 'string'
? payload.toolName
: typeof payload.name === 'string'
? payload.name
: undefined;
return candidate ? redactString(candidate).slice(0, 240) : undefined;
}
function deterministicArtifactId(input: RunEventAppendInput, eventId: string): string {
const identity =
input.dedupeKey ??
(input.providerEventId ? `${input.source.provider}:${input.providerEventId}` : eventId);
const digest = createHash('sha256')
.update(
`${input.workspaceId ?? 'local'}:${input.taskId}:${input.attemptId}:${input.kind}:${identity}`
)
.digest('base64url')
.slice(0, 24);
return `spill_${digest}`;
}
let fileRepository: FileRunEventRepository | undefined;
function defaultRepository(): RunEventRepository {
@ -176,17 +219,58 @@ function defaultRepository(): RunEventRepository {
export class RunEventJournalService {
private readonly listeners = new Set<RunEventListener>();
private resolvedArtifactService?: Pick<RunOutputArtifactService, 'spill'>;
constructor(private readonly repository?: RunEventRepository) {}
constructor(
private readonly repository?: RunEventRepository,
private readonly artifactService?: Pick<RunOutputArtifactService, 'spill'>
) {}
async append(input: RunEventAppendInput): Promise<RunEventAppendResult> {
validatePathSegment(input.taskId);
validatePathSegment(input.attemptId);
const eventId = `runevt_${nanoid(18)}`;
const sanitized = sanitizePayload(input.payload);
const payloadJson = JSON.stringify(sanitized.payload);
const serialized = serializeForSpill(input.payload);
const spillPreview =
serialized && countBytes(serialized) > MAX_STRING_BYTES
? await this.getArtifactService().spill({
scope: {
workspaceId: input.workspaceId ?? 'local',
taskId: input.taskId,
runId: input.attemptId,
attemptId: input.attemptId,
turnId: input.turnId,
},
source: {
kind: spillSourceKind(input.kind),
name: spillSourceName(input.payload),
eventId,
toolCallId:
typeof input.payload.toolCallId === 'string'
? input.payload.toolCallId
: input.itemId,
commandId:
typeof input.payload.commandId === 'string' ? input.payload.commandId : undefined,
},
content: serialized,
mediaType: 'application/json',
truncationReason: 'event-limit',
artifactId: deterministicArtifactId(input, eventId),
})
: undefined;
const eventPayload = spillPreview
? {
spilled: true,
originalPayloadBytes: countBytes(serialized ?? ''),
outputArtifact: spillPreview,
}
: sanitized.payload;
const boundedEventPayload = sanitizePayload(eventPayload);
const payloadJson = JSON.stringify(boundedEventPayload.payload);
const event = RunEventEnvelopeSchema.parse({
schemaVersion: RUN_EVENT_SCHEMA_VERSION,
eventId: `runevt_${nanoid(18)}`,
eventId,
taskId: input.taskId,
runId: input.attemptId,
attemptId: input.attemptId,
@ -202,12 +286,15 @@ export class RunEventJournalService {
kind: input.kind,
source: input.source,
redaction: {
status: sanitized.status,
fields: sanitized.fields,
originalBytes: sanitized.originalBytes,
persistedBytes: sanitized.persistedBytes,
status: spillPreview ? 'redacted' : boundedEventPayload.status,
fields: spillPreview ? ['$'] : boundedEventPayload.fields,
originalBytes: Math.max(
sanitized.originalBytes,
serialized ? countBytes(serialized) : sanitized.originalBytes
),
persistedBytes: boundedEventPayload.persistedBytes,
},
payload: sanitized.payload,
payload: boundedEventPayload.payload,
payloadHash: sha256(payloadJson),
dedupeKey:
input.dedupeKey ??
@ -230,6 +317,11 @@ export class RunEventJournalService {
return result;
}
private getArtifactService(): Pick<RunOutputArtifactService, 'spill'> {
this.resolvedArtifactService ??= this.artifactService ?? new RunOutputArtifactService();
return this.resolvedArtifactService;
}
async list(query: RunEventQuery): Promise<RunEventPage> {
validatePathSegment(query.taskId);
validatePathSegment(query.attemptId);

View file

@ -167,6 +167,23 @@ export class RunOutputArtifactService {
return metadata;
}
async readDownloadRange(
lookup: RunOutputArtifactLookup,
requesterId: string,
offset: number,
length: number,
now = new Date()
) {
const metadata = await this.validateForExport(lookup, now);
if (!metadata || metadata.state !== 'available') return null;
this.consumeRateLimit(requesterId, metadata.id, now);
return this.repository.readRange({
...lookup,
offset,
length: Math.min(Math.max(length, 1), 4 * 1024 * 1024),
});
}
private async queryByteRange(
metadata: RunOutputArtifactMetadata,
lookup: RunOutputArtifactLookup,

View file

@ -45,6 +45,7 @@ export interface PrepareRunOutputInput {
content: string | Uint8Array;
mediaType?: string;
truncationReason?: RunOutputTruncationReason;
artifactId?: string;
now?: Date;
}
@ -246,7 +247,7 @@ export class RunOutputSpillService {
: (input.truncationReason ?? 'inline-limit');
const state = unsafeByPolicy ? 'quarantined' : 'available';
const operations = unsafeByPolicy ? ['metadata'] : operationsFor(classified.contentClass);
const artifactId = `spill_${nanoid(24)}`;
const artifactId = input.artifactId ?? `spill_${nanoid(24)}`;
const createdAt = now.toISOString();
const metadata = RunOutputArtifactMetadataSchema.parse({
schemaVersion: RUN_OUTPUT_ARTIFACT_SCHEMA_VERSION,

View file

@ -9,6 +9,7 @@ export type DataLifecycleClassId =
| 'workProducts'
| 'telemetry'
| 'workflowRuns'
| 'runOutputArtifacts'
| 'notifications'
| 'chat'
| 'audit'

View file

@ -89,6 +89,7 @@ export interface RunEventEnvelope {
}
export interface RunEventAppendInput {
workspaceId?: string;
taskId: string;
attemptId: string;
sessionId?: string;