Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions packages/runtime-core/src/agent-task-run-result.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isArtifactBundleRef, isChangedFilesArtifactRef, isEvidenceBundleArtifactRef, isLogArtifactRef, isPatchArtifactRef, isRuntimeArtifactRef, isTranscriptArtifactRef } from "./artifact-ref-classification.js"
import { isPlainObject, numberValue, objectValue, stringValue, stripUndefined } from "./object-utils.js"
import { normalizeAgentTerminalResult, type AgentTerminalResult } from "./agent-terminal-result.js"
import { RUNTIME_ACCESS_SCHEMA, normalizeRuntimeAccess, type RuntimeAccess } from "./runtime-boundary-contracts.js"
Expand Down Expand Up @@ -114,13 +115,13 @@ export function normalizeAgentTaskRunResult(raw: unknown, options: AgentTaskRunR
summary: stringValue(result.summary) || stringValue(result.message) || stringValue(agentResult.summary) || defaultSummary(status),
artifacts,
refs: {
artifact_bundles: artifacts.filter((artifact) => artifact.kind === "artifact-bundle" || artifact.kind === "codebox-artifact-bundle"),
changed_files: artifacts.filter((artifact) => artifact.kind === "codebox-changed-files"),
patches: artifacts.filter((artifact) => artifact.kind === "codebox-patch"),
transcripts: artifacts.filter((artifact) => artifact.kind === "codebox-transcript"),
logs: artifacts.filter((artifact) => artifact.kind === "codebox-runtime-log" || artifact.kind === "codebox-command-log"),
runtimes: artifacts.filter((artifact) => artifact.kind === "codebox-runtime"),
evidence_bundles: artifacts.filter((artifact) => artifact.kind === "evidence-bundle" || artifact.kind === "codebox-evidence-bundle"),
artifact_bundles: artifacts.filter((artifact) => isArtifactBundleRef(artifact)),
changed_files: artifacts.filter((artifact) => isChangedFilesArtifactRef(artifact)),
patches: artifacts.filter((artifact) => isPatchArtifactRef(artifact)),
transcripts: artifacts.filter((artifact) => isTranscriptArtifactRef(artifact)),
logs: artifacts.filter((artifact) => isLogArtifactRef(artifact)),
runtimes: artifacts.filter((artifact) => isRuntimeArtifactRef(artifact)),
evidence_bundles: artifacts.filter((artifact) => isEvidenceBundleArtifactRef(artifact)),
},
diagnostics: [...arrayObjects(result.diagnostics), ...(terminalResult?.diagnostics ?? [])],
metadata: stripUndefined({
Expand Down
97 changes: 97 additions & 0 deletions packages/runtime-core/src/artifact-ref-classification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Canonical artifact reference classification.
*
* Artifact reference kinds are classified in one place so every projection
* derives its groups from the same rules instead of restating them.
*
* Classification has two modes, and the difference is a trust boundary rather
* than an inconsistency:
*
* - `"typed"` accepts only explicitly declared artifact kinds. Projections that
* feed the workspace delta use this mode, because a reference classified as
* changed files or a patch describes changes a caller may apply. A path that
* merely looks like `files/patch.diff` must never earn that trust.
* - `"discovery"` also infers from artifact paths. Read-only projections that
* list, link, or display references use this mode so partially typed bundles
* stay discoverable.
*
* Callers compose their own group sets from these predicates; the group shape a
* surface publishes is its own concern, but what makes a reference a patch, a
* transcript, or a log is decided here.
*/

export const CHANGED_FILES_ARTIFACT_PATH = "files/changed-files.json" as const
export const PATCH_ARTIFACT_PATH = "files/patch.diff" as const
export const ARTIFACT_MANIFEST_PATH = "manifest.json" as const

export type ArtifactRefClassificationMode = "typed" | "discovery"

export interface ClassifiableArtifactRef {
kind: string
path?: string
}

export function isArtifactBundleRef(ref: ClassifiableArtifactRef): boolean {
return ref.kind === "artifact-bundle" || ref.kind === "codebox-artifact-bundle"
}

export function isEvidenceBundleArtifactRef(ref: ClassifiableArtifactRef): boolean {
return ref.kind === "evidence-bundle" || ref.kind === "codebox-evidence-bundle"
}

export function isRuntimeArtifactRef(ref: ClassifiableArtifactRef): boolean {
return ref.kind === "codebox-runtime"
}

export function isChangedFilesArtifactRef(ref: ClassifiableArtifactRef, mode: ArtifactRefClassificationMode = "typed"): boolean {
if (ref.kind === "codebox-changed-files") return true
if (mode === "typed") return false
return ref.kind === "changed-files" || pathEndsWith(ref.path, CHANGED_FILES_ARTIFACT_PATH)
}

export function isPatchArtifactRef(ref: ClassifiableArtifactRef, mode: ArtifactRefClassificationMode = "typed"): boolean {
if (ref.kind === "codebox-patch") return true
if (mode === "typed") return false
return ref.kind === "patch" || pathEndsWith(ref.path, PATCH_ARTIFACT_PATH)
}

export function isTranscriptArtifactRef(ref: ClassifiableArtifactRef, mode: ArtifactRefClassificationMode = "typed"): boolean {
return mode === "typed" ? ref.kind === "codebox-transcript" : ref.kind.includes("transcript")
}

export function isLogArtifactRef(ref: ClassifiableArtifactRef, mode: ArtifactRefClassificationMode = "typed"): boolean {
if (ref.kind === "codebox-runtime-log" || ref.kind === "codebox-command-log") return true
if (mode === "typed") return false
return ref.kind.includes("log") || pathHasExtension(ref.path, ".log") || pathHasExtension(ref.path, ".jsonl")
}

export function isBrowserArtifactRef(ref: ClassifiableArtifactRef): boolean {
return ref.kind.startsWith("browser-") || pathIncludes(ref.path, "/browser/")
}

/**
* Discovery-mode kind inference for references that arrive without a kind.
* Typed projections never call this; they require a declared kind.
*/
export function kindForArtifactPath(path: string | undefined): string | undefined {
if (pathEndsWith(path, CHANGED_FILES_ARTIFACT_PATH)) return "codebox-changed-files"
if (pathEndsWith(path, PATCH_ARTIFACT_PATH)) return "codebox-patch"
if (pathEndsWith(path, ARTIFACT_MANIFEST_PATH)) return "artifact-manifest"
return undefined
}

/** Matches a trailing path segment, so `files/patch.diff` matches but `my-patch.diff` does not. */
function pathEndsWith(path: string | undefined, suffix: string): boolean {
return typeof path === "string" && (path === suffix || path.endsWith(`/${suffix}`))
}

/** Matches a file extension on the final path segment. */
function pathHasExtension(path: string | undefined, extension: string): boolean {
if (typeof path !== "string") return false
const name = path.slice(path.lastIndexOf("/") + 1)
return name.length > extension.length && name.endsWith(extension)
}

function pathIncludes(path: string | undefined, fragment: string): boolean {
return typeof path === "string" && path.includes(fragment)
}
36 changes: 9 additions & 27 deletions packages/runtime-core/src/artifact-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ import type { RuntimeReferenceManifestArtifactBundleRef, RuntimeReferenceManifes
import { redactJsonValue } from "./redaction.js"
import { BROWSER_SESSION_PRODUCT_DTO_SCHEMA, normalizeRuntimeAccess, type RuntimeAccess } from "./runtime-boundary-contracts.js"

import { ARTIFACT_MANIFEST_PATH, CHANGED_FILES_ARTIFACT_PATH, PATCH_ARTIFACT_PATH, isArtifactBundleRef, isBrowserArtifactRef, isChangedFilesArtifactRef, isLogArtifactRef, isPatchArtifactRef, isTranscriptArtifactRef, kindForArtifactPath } from "./artifact-ref-classification.js"

export { ARTIFACT_MANIFEST_PATH, CHANGED_FILES_ARTIFACT_PATH, PATCH_ARTIFACT_PATH }

export const METADATA_ARTIFACT_PATH = "metadata.json" as const
export const REVIEW_ARTIFACT_PATH = "files/review.json" as const
export const RUNTIME_EPISODE_TRACE_ARTIFACT_PATH = "files/runtime-episode-trace.json" as const
export const RUNTIME_EPISODE_EVENTS_ARTIFACT_PATH = "files/runtime-episode.jsonl" as const
export const RUNTIME_REFERENCE_MANIFEST_ARTIFACT_PATH = "files/runtime-reference-manifest.json" as const
export const RUNTIME_REPLAY_REFERENCE_INDEX_ARTIFACT_PATH = "files/runtime-replay-index.json" as const
export const RUNTIME_SNAPSHOT_ARTIFACT_PATH = "files/runtime-snapshot.json" as const
export const CHANGED_FILES_ARTIFACT_PATH = "files/changed-files.json" as const
export const PATCH_ARTIFACT_PATH = "files/patch.diff" as const
export const ARTIFACT_MANIFEST_PATH = "manifest.json" as const
export const PUBLIC_ARTIFACT_REF_DTO_SCHEMA = "wp-codebox/artifact-ref/v1" as const

const RUNTIME_REFERENCE_MANIFEST_EXCLUDED_PATHS = new Set<string>([
Expand Down Expand Up @@ -195,11 +196,11 @@ export function publicArtifactRefGroups(input: unknown): PublicArtifactRefGroups
return {
all,
artifact_bundles: all.filter(isArtifactBundleRef),
changed_files: all.filter(isChangedFilesArtifactRef),
patches: all.filter(isPatchArtifactRef),
browser: all.filter((ref) => ref.kind.startsWith("browser-") || pathIncludes(ref.path, "/browser/")),
logs: all.filter((ref) => ref.kind.includes("log") || pathEndsWith(ref.path, ".log") || pathEndsWith(ref.path, ".jsonl")),
transcripts: all.filter((ref) => ref.kind.includes("transcript")),
changed_files: all.filter((ref) => isChangedFilesArtifactRef(ref, "discovery")),
patches: all.filter((ref) => isPatchArtifactRef(ref, "discovery")),
browser: all.filter(isBrowserArtifactRef),
logs: all.filter((ref) => isLogArtifactRef(ref, "discovery")),
transcripts: all.filter((ref) => isTranscriptArtifactRef(ref, "discovery")),
}
}

Expand Down Expand Up @@ -496,25 +497,6 @@ function richerPublicArtifactRef(existing: PublicArtifactRefDTO, incoming: Publi
})
}

function isArtifactBundleRef(ref: PublicArtifactRefDTO): boolean {
return ref.kind === "artifact-bundle" || ref.kind === "codebox-artifact-bundle"
}

function isChangedFilesArtifactRef(ref: PublicArtifactRefDTO): boolean {
return ref.kind === "codebox-changed-files" || ref.kind === "changed-files" || pathEndsWith(ref.path, CHANGED_FILES_ARTIFACT_PATH)
}

function isPatchArtifactRef(ref: PublicArtifactRefDTO): boolean {
return ref.kind === "codebox-patch" || ref.kind === "patch" || pathEndsWith(ref.path, PATCH_ARTIFACT_PATH)
}

function kindForArtifactPath(path: string | undefined): string | undefined {
if (pathEndsWith(path, CHANGED_FILES_ARTIFACT_PATH)) return "codebox-changed-files"
if (pathEndsWith(path, PATCH_ARTIFACT_PATH)) return "codebox-patch"
if (pathEndsWith(path, ARTIFACT_MANIFEST_PATH)) return "artifact-manifest"
return undefined
}

function pathEndsWith(path: string | undefined, suffix: string): boolean {
return typeof path === "string" && (path === suffix || path.endsWith(`/${suffix}`))
}
Expand Down
40 changes: 40 additions & 0 deletions tests/artifact-ref-classification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import assert from "node:assert/strict"
import { isChangedFilesArtifactRef, isLogArtifactRef, isPatchArtifactRef, isTranscriptArtifactRef } from "../packages/runtime-core/src/artifact-ref-classification.js"
import { normalizeAgentTaskRunResult, publicArtifactRefGroups, workspaceDeltaFromAgentTaskRunResult } from "../packages/runtime-core/src/index.js"

// A reference that only looks like a patch by path must never be trusted as one.
// Typed classification feeds the workspace delta, and a delta patch can be applied.
const untypedPatch = { kind: "artifact", path: "files/patch.diff" }
const untypedChangedFiles = { kind: "artifact", path: "files/changed-files.json" }

assert.equal(isPatchArtifactRef(untypedPatch), false, "typed mode rejects path-inferred patches")
assert.equal(isChangedFilesArtifactRef(untypedChangedFiles), false, "typed mode rejects path-inferred changed files")
assert.equal(isPatchArtifactRef(untypedPatch, "discovery"), true, "discovery mode infers patches from path")
assert.equal(isChangedFilesArtifactRef(untypedChangedFiles, "discovery"), true, "discovery mode infers changed files from path")

// Declared kinds are trusted in both modes.
for (const mode of ["typed", "discovery"] as const) {
assert.equal(isPatchArtifactRef({ kind: "codebox-patch" }, mode), true, `declared patch kind is classified in ${mode} mode`)
assert.equal(isChangedFilesArtifactRef({ kind: "codebox-changed-files" }, mode), true, `declared changed-files kind is classified in ${mode} mode`)
assert.equal(isTranscriptArtifactRef({ kind: "codebox-transcript" }, mode), true, `declared transcript kind is classified in ${mode} mode`)
assert.equal(isLogArtifactRef({ kind: "codebox-runtime-log" }, mode), true, `declared runtime log kind is classified in ${mode} mode`)
}

// Loosely named kinds stay out of typed groups but remain discoverable.
assert.equal(isTranscriptArtifactRef({ kind: "agent-transcript" }), false, "typed mode requires the declared transcript kind")
assert.equal(isTranscriptArtifactRef({ kind: "agent-transcript" }, "discovery"), true, "discovery mode matches transcript-like kinds")
assert.equal(isLogArtifactRef({ kind: "artifact", path: "files/run.jsonl" }), false, "typed mode requires a declared log kind")
assert.equal(isLogArtifactRef({ kind: "artifact", path: "files/run.jsonl" }, "discovery"), true, "discovery mode infers logs from path")

// The two consumers of the classifier keep their respective modes end to end.
const untypedArtifacts = [untypedChangedFiles, untypedPatch]
const runResult = normalizeAgentTaskRunResult({ success: true, artifacts: untypedArtifacts })
assert.deepEqual(runResult.refs.patches, [], "agent task run refs stay typed")
assert.deepEqual(runResult.refs.changed_files, [], "agent task run refs reject inferred changed files")
assert.equal(workspaceDeltaFromAgentTaskRunResult(runResult).status, "unavailable", "workspace delta refuses untyped change evidence")

const discovered = publicArtifactRefGroups({ artifacts: untypedArtifacts })
assert.equal(discovered.patches.length, 1, "public discovery projection still surfaces the patch")
assert.equal(discovered.changed_files.length, 1, "public discovery projection still surfaces changed files")

console.log("artifact ref classification ok")
Loading