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
12 changes: 12 additions & 0 deletions src/lib/payload/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ function validateArtifact(artifact: ArtifactPayload): string | null {
return null;
}

/**
* Validates and normalizes a payload envelope before encode/render use.
*
* Validation guarantees:
* - artifact ids are unique within the bundle (duplicate ids fail validation)
* - diff artifacts include either a non-empty `patch` or both `oldContent` and `newContent`
* - at least one artifact exists
*
* Normalization behavior:
* - `activeArtifactId` is preserved only when it matches an artifact in the bundle
* - otherwise `activeArtifactId` is normalized to the first artifact id
*/
export function normalizeEnvelope(envelope: PayloadEnvelope): EnvelopeValidationResult {
const seenIds = new Set<string>();

Expand Down
44 changes: 44 additions & 0 deletions src/lib/payload/fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,33 @@ function selectCandidate(candidates: CandidateFragment[], budget?: number): Cand
return inBudget ?? sorted[0];
}

/**
* Encodes a payload envelope into the fragment body expected after `#`, for example
* `agent-render=v1.deflate.<payload>`.
*
* The sync encoder supports `plain`, `lz`, and `deflate` codecs. If options explicitly
* require `arx`, this function throws because `arx` compression is async-only.
*
* Candidate encodings are generated across enabled codecs (and packed/unpacked wire
* transport variants) and the shortest transport length is selected, or the shortest option
* that fits `targetMaxFragmentLength` when provided.
*/
export function encodeEnvelope(envelope: PayloadEnvelope, options: EncodeOptions = {}): string {
const selected = selectCandidate(buildCandidates(envelope, options), options.targetMaxFragmentLength);
return selected.value;
}

/**
* Async variant of {@link encodeEnvelope} that also supports `arx` candidates.
*
* Returns the fragment body string expected after `#`, for example
* `agent-render=v1.arx.<dictVersion>.<payload>` for `arx`, or
* `agent-render=v1.<codec>.<payload>` for `plain|lz|deflate`.
*
* Like the sync version, candidate encodings are generated across enabled codecs and packed
* transport variants, then the smallest transport representation is selected (or the smallest
* candidate within `targetMaxFragmentLength`, when available).
*/
export async function encodeEnvelopeAsync(envelope: PayloadEnvelope, options: EncodeOptions = {}): Promise<string> {
const candidates = await buildCandidatesAsync(envelope, options);
const selected = selectCandidate(candidates, options.targetMaxFragmentLength);
Expand Down Expand Up @@ -259,6 +281,17 @@ function parseFragmentHeader(hash: string): ParsedFragmentHeader {
return { ok: true, fragment, version, codec: codecRaw as PayloadCodec, encoded, fragmentLength: fragment.length };
}

/**
* Decodes a fragment string (with or without leading `#`) into a parsed payload result.
*
* Expected fragment format is `#agent-render=v1.<codec>.<payload>`. Validation first enforces
* fragment-key and shape checks and the global fragment size budget. On failure, returns a
* structured `ParsedPayload` error (`empty`, `missing-key`, `invalid-format`, `too-large`,
* `invalid-json`, `decoded-too-large`, or `invalid-envelope`) instead of throwing.
*
* Sync decoding supports `plain`, `lz`, and `deflate` codecs only. `arx` fragments return an
* `invalid-format` result instructing callers to use {@link decodeFragmentAsync}.
*/
export function decodeFragment(hash: string): ParsedPayload {
const header = parseFragmentHeader(hash);
if (!header.ok) {
Expand Down Expand Up @@ -347,6 +380,17 @@ function resolveArxDictVersion(version: number | null): boolean {
return version === getActiveDictVersion();
}

/**
* Async fragment decoder that supports all codecs, including `arx`.
*
* Accepted input follows `#agent-render=v1...` where non-`arx` payloads use
* `v1.<codec>.<payload>`, and `arx` supports `v1.arx.<dictVersion>.<payload>` (plus legacy
* fallback forms). This decoder applies the same header and fragment-size checks as the sync
* path, then enforces decoded payload size limits before JSON parsing and envelope validation.
*
* Returns structured `ParsedPayload` error responses for malformed fragments or invalid
* envelopes, rather than throwing decode errors.
*/
export async function decodeFragmentAsync(hash: string): Promise<ParsedPayload> {
const header = parseFragmentHeader(hash);
if (!header.ok) {
Expand Down
27 changes: 27 additions & 0 deletions src/lib/payload/link-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ function buildArtifact(draft: LinkCreatorDraft): ArtifactPayload {
};
}

/**
* Builds a single-artifact payload envelope from link-creator draft input.
*
* Throws when the draft content/body is empty (including whitespace-only input). Returned
* envelopes are not yet validated for bundle invariants; callers should run
* {@link normalizeEnvelope} before encoding.
*/
export function createDraftEnvelope(draft: LinkCreatorDraft): PayloadEnvelope {
const artifact = buildArtifact(draft);

Expand All @@ -104,6 +111,16 @@ export function createDraftEnvelope(draft: LinkCreatorDraft): PayloadEnvelope {
};
}

/**
* Generates a shareable artifact link from draft input using sync codecs.
*
* Throws when draft content is empty, envelope normalization fails, or the generated fragment
* exceeds `MAX_FRAGMENT_LENGTH`. The returned object always includes:
* - `hash`: `#agent-render=v1...` fragment string
* - `url`: either the hash-only URL or `baseUrl` with hash attached
* - `fragmentLength`: character count excluding the leading `#`
* - `envelope` and `artifact`: the normalized payload envelope and its single artifact
*/
export function createGeneratedArtifactLink(draft: LinkCreatorDraft, baseUrl?: string): GeneratedArtifactLink {
const normalized = normalizeEnvelope(createDraftEnvelope(draft));

Expand Down Expand Up @@ -137,6 +154,13 @@ export function createGeneratedArtifactLink(draft: LinkCreatorDraft, baseUrl?: s
};
}

/**
* Async variant of {@link createGeneratedArtifactLink} that can leverage async codecs (including
* `arx`) via {@link encodeEnvelopeAsync}.
*
* Error and return semantics match the sync variant: throws on invalid draft/normalized payload
* or over-budget fragments, and returns `{ hash, url, fragmentLength, envelope, artifact }`.
*/
export async function createGeneratedArtifactLinkAsync(draft: LinkCreatorDraft, baseUrl?: string): Promise<GeneratedArtifactLink> {
const normalized = normalizeEnvelope(createDraftEnvelope(draft));

Expand Down Expand Up @@ -170,6 +194,9 @@ export async function createGeneratedArtifactLinkAsync(draft: LinkCreatorDraft,
};
}

/**
* Returns the body-field label used by the link creator for the selected artifact kind.
*/
export function getBodyFieldLabel(kind: ArtifactKind) {
return kind === "diff" ? "Patch" : "Content";
}
15 changes: 15 additions & 0 deletions src/lib/payload/wire-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ function isRecord(value: unknown): value is RecordValue {
return typeof value === "object" && value !== null;
}

/**
* Packs a full payload envelope into the compact wire transport shape (`p: 1`) used when
* fragment size pressure favors shorter field keys.
*
* Packed envelopes keep the same semantic content while mapping verbose keys to compact aliases
* (`v/c/t/a/r`, etc). This representation is an internal transport optimization and is expected
* to round-trip through {@link unpackEnvelope} back to the standard envelope contract.
*/
export function packEnvelope(envelope: PayloadEnvelope): PackedEnvelope {
return {
p: 1,
Expand Down Expand Up @@ -161,6 +169,13 @@ function looksLikePackedEnvelope(value: unknown): value is PackedEnvelope {
return value.r.every(looksLikePackedArtifact);
}

/**
* Expands a decoded value from packed wire transport back into the standard payload envelope
* shape when it matches the `p: 1` format.
*
* If the value does not look like a compatible packed envelope, the input is returned unchanged
* so existing non-packed payloads remain fully compatible.
*/
export function unpackEnvelope(value: unknown): unknown {
if (!looksLikePackedEnvelope(value)) {
return value;
Expand Down
Loading