feat(update): support JSON Patch operations for token-efficient edits#288
feat(update): support JSON Patch operations for token-efficient edits#288revanth-traceable wants to merge 13 commits into
Conversation
Adds a second mode to harness_update: pass `operations` (RFC 6902 JSON Patch) to change specific fields without resending the full resource. The tool fetches the current resource, applies the patch server-side, and sends the merged result. `body` (full replacement) and `operations` are mutually exclusive; `dry_run` previews the diff without writing. Works for YAML-backed resources (pipeline, template, input_set) and JSON resources (connector, service, etc.), preserving git metadata (lastObjectId/lastCommitId/storeType) for optimistic concurrency. On a 526-line pipeline, a two-part edit costs ~367 output tokens via operations vs ~4,030 via full-body replacement (~11x less generated text); the gap grows ~26x per additional edit. Co-authored-by: Cursor <cursoragent@cursor.com>
Restore the pipeline/URL/openInHarness guidance dropped in the prior edit, scope the YAML-string note to full replacement, and drop the server-side merge detail already covered by the operations param. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Re-reviewed PR #288 at current head 126195f. I’d still block on these: • Critical src/tools/harness-update.ts:54, src/tools/harness-update.ts:102 • Critical src/utils/json-patch.ts:52, src/tools/harness-update.ts:181 • Important src/utils/json-patch.ts:20 • Important src/tools/harness-update.ts:152, src/tools/harness-update.ts:202 • Test gap Verification run: |
Co-authored-by: Rohan Gupta <thisrohangupta@users.noreply.github.com>
|
|
There was a problem hiding this comment.
Stale comment
Found 2 issues against Sunil's architecture standards:
src/tools/harness-update.ts: the new JSON Patch path does not preserve the existing v0templatefallback forversion_label, soharness_update(..., operations=[...])can fail unless callers know to passparams.version_label. The full-body path still defaults tov1, so this is a behavior regression in the new patch flow.src/registry/types.ts: the newpatchSupportregistry metadata never reachesharness_describe/Registry.describe(). That breaks the repo's structured-discovery model, because agents still cannot tell which resource types acceptoperationswithout trial-and-error.Assumption: I’m treating JSON Patch support as part of the agent-facing contract because this PR introduces registry metadata for it rather than keeping the allowlist purely internal.
Verification:
- GitHub CI on
d9ceadeis green (build-and-teston Node 20/22 plus all smoke-test jobs).- Local:
pnpm typecheck- Local:
pnpm build- Local:
pnpm test tests/utils/json-patch.test.ts tests/tools/tool-handlers.test.ts- Local:
pnpm test(1604tests passed)Change summary: this PR adds JSON Patch support to
harness_update, introduces registry-levelpatchSupportmetadata for YAML-backed resources, and adds focused handler/unit coverage for the new flow.Sent by Cursor Automation: Sunil On Demand Architecture Review
| } | ||
|
|
||
| const { params, operations: _ops, dry_run: _dry, confirm: _confirm, ...rest } = args; | ||
| const getInput = applyUrlDefaults({ ...rest } as Record<string, unknown>, args.url); |
There was a problem hiding this comment.
Patch mode no longer mirrors the existing v0 template fallback here. handleFullBodyUpdate() defaults resource_type === "template" to version_label = "v1", but this path never does that before the eventual update dispatch. Because template.update uses /template/api/templates/update/{templateIdentifier}/{versionLabel}, a patch call like harness_update({ resource_type: "template", resource_id: "...", operations: [...] }) will fail unless the caller already knows to pass params.version_label, even though the full-body path still works without it.
| * update body from the resource's GET response. Omit this for resources whose | ||
| * read shape differs from their write shape. | ||
| */ | ||
| patchSupport?: { |
There was a problem hiding this comment.
Adding patchSupport as registry metadata is the right direction, but Sunil's standards prefer agent guidance to live in structured metadata that harness_describe exposes. Right now Registry.describe() / harness_describe never surface this field, so agents still cannot discover which resource types support operations vs full body without trial-and-error. Can we thread this through the describe surface in the same PR?
There was a problem hiding this comment.
Stale comment
Found 3 issues against the requested review axes.
- Important —
src/tools/harness-update.ts,tests/tools/tool-handlers.test.ts: JSON Patch updates fortemplateare not backward-compatible with the existing implicitversion_label="v1"update path. Trigger: callharness_update(resource_type="template", resource_id="...", operations=[...])withoutparams.version_label. Result: patch mode fails withMissing required field "version_label", while the legacy body-based update still succeeds. This breaks compatibility with existing update semantics and the new tests never exercise the omitted-version_labelcase.- Important —
src/registry/types.ts,src/registry/toolsets/pipelines.ts,src/registry/toolsets/templates.ts: the newpatchSupportmetadata is runtime-only. Trigger: an agent callsharness_describe(resource_type="pipeline")orresource_type="template"to discover whetheroperationsis safe. Result: describe output omits patch capability entirely, so agents cannot tell which resource types support JSON Patch. That violates the repo’s discovery standard to surface agent guidance as structured metadata rather than hidden runtime state.- Important —
src/tools/harness-update.ts,tests/tools/tool-handlers.test.ts,tests/utils/json-patch.test.ts: the new operation schema does not encode RFC 6902 field requirements. Trigger: sendreplace/add/testwithoutvalue, ormove/copywithoutfrom. Result: the request is accepted, confirmation runs, and a GET is made beforefast-json-patchthrows. That falls short of the project’s Zod-first type-safety standard and the new tests only cover utility-level failures, not handler-level schema rejection.Sent by Cursor Automation: Sunil On Demand Architecture Review
| const serialized = serializeBody(patched, yamlSource); | ||
| const updateInput: Record<string, unknown> = { ...getInput, body: serialized }; | ||
|
|
||
| if (metadata.lastObjectId) updateInput.last_object_id = metadata.lastObjectId; |
There was a problem hiding this comment.
Important: the patch path never normalizes version_label for template, while handleFullBodyUpdate() still falls back to "v1" when callers omit it. That means harness_update(resource_type="template", resource_id="my_tpl", operations=[...]) now fails with Missing required field "version_label" even though the legacy body-based call still succeeds. I reproduced this locally against the built handler: the patch path errors, while the full-body path returns success. There's also no regression test for the omitted-version_label case in tests/tools/tool-handlers.test.ts.
| * update body from the resource's GET response. Omit this for resources whose | ||
| * read shape differs from their write shape. | ||
| */ | ||
| patchSupport?: { |
There was a problem hiding this comment.
Important: adding patchSupport here does not help agents unless the capability is exposed through Registry.describe() / harness_describe. Right now a caller asking harness_describe(resource_type="pipeline") still gets no signal that pipeline/input_set/template are patchable while pull_request is not, so this behavior-changing metadata is effectively hidden. That violates the repo's data over prose discovery rule.
| value: z.unknown().optional().describe("Value for add/replace/test operations"), | ||
| from: z.string().optional().describe("Source JSON Pointer for move/copy operations"), |
There was a problem hiding this comment.
Important: this schema makes value and from optional for every op, so malformed RFC 6902 payloads pass tool validation and only fail after confirmation plus a GET round trip. For example, operations:[{op:"replace", path:"/pipeline/name"}] currently reaches the handler, fetches the pipeline, and then dies inside fast-json-patch. Given the project's Zod-first typing standard, these requirements should be enforced at the tool schema boundary, and the new tests don't cover that handler-level validation path.
There was a problem hiding this comment.
Stale comment
Found 3 issues that keep this from meeting Sunil's architecture standards:
- The new JSON Patch path for
templateupdates does not preserve the existing implicitversion_labelfallback, so the alternate mutation mode is not behaviorally compatible with the legacy full-body path.patchSupportis added to registry metadata but never reachesharness_describe, which leaves patchability discoverable only through prose instead of structured metadata.- The
operationsinput schema is too permissive for RFC 6902, so malformed patch payloads make it past tool validation and only fail after confirmation plus a GET.Verification run:
pnpm exec vitest run tests/tools/tool-handlers.test.ts tests/utils/json-patch.test.tspnpm buildSent by Cursor Automation: Sunil On Demand Architecture Review
| const serialized = serializeBody(patched, yamlSource); | ||
| const updateInput: Record<string, unknown> = { ...getInput, body: serialized }; | ||
|
|
||
| if (metadata.lastObjectId) updateInput.last_object_id = metadata.lastObjectId; |
There was a problem hiding this comment.
handleFullBodyUpdate() still preserves the legacy template convenience fallback by setting input.version_label = "v1" when callers omit it, but the new patch path copies getInput straight into updateInput and never applies the same normalization. That means resource_type: "template" operations-based updates can now fail in Registry.dispatch() with Missing required field "version_label" for template, even though the equivalent full-body update still works. The alternate mutation mode should preserve the existing default or recover the version from the GET response before dispatching the update.
| * update body from the resource's GET response. Omit this for resources whose | ||
| * read shape differs from their write shape. | ||
| */ | ||
| patchSupport?: { |
There was a problem hiding this comment.
Adding patchSupport only in ResourceDefinition leaves the new capability trapped in server-side metadata. harness_describe() / Registry.describe() never expose this, so agents still have no machine-readable way to discover which resource types support operations and which ones require full-body updates. That cuts against the repo's data over prose guidance: the runtime knows the capability, but the discovery surface does not. Please surface patch capability metadata through describe output and cover it with a regression test.
| const patchOperationSchema = z.object({ | ||
| op: z.enum(["add", "remove", "replace", "move", "copy", "test"]).describe("RFC 6902 operation type"), | ||
| path: z.string().describe("JSON Pointer (RFC 6901) to the target location, e.g. /pipeline/stages/0/stage/spec/execution/steps/0/step/spec/command"), | ||
| value: z.unknown().optional().describe("Value for add/replace/test operations"), |
There was a problem hiding this comment.
This schema is too permissive for RFC 6902. Because value and from are optional for every operation, malformed payloads like { op: "replace", path: "/pipeline/name" } or { op: "move", path: "/x" } clear tool validation, prompt for confirmation, and even do the GET before fast-json-patch rejects them. That violates the repo's validate-early / fail-loudly standards and makes bad requests more expensive than they need to be. A discriminated union per op would reject these before any confirmation or network work.
There was a problem hiding this comment.
Stale comment
Found 3 architecture-standard issues in the current head.
- Important: the new JSON Patch path round-trips YAML through
YAML.parse()/YAML.stringify(), so a one-field patch on a Git-backed pipeline/template/input set still rewrites the full YAML text and drops comments/original formatting. That is not minimal-impact behavior for a feature positioned as targeted edits.- Important: JSON Patch operation shape is validated up front, but
path/fromare still plain strings. Malformed pointers can get through the MCP schema and only fail after the GET/apply path, which misses the repo's fail-loud / fully-typed-input bar.- Suggestion:
harness_describeadvertisespatchSupport.dryRun, while the actual public tool input isdry_run. That makes the structured discovery metadata disagree with the runtime contract.Open question: if preserving YAML comments/format is intentionally out of scope for the first cut, I would at least scope the feature/docs to make it explicit that JSON Patch here is semantic-only and may rewrite the underlying YAML text.
Verification run on
38f2a28adc81aa10bb9b272b92c30479e52ca31a:
pnpm testpnpm typecheckpnpm buildSent by Cursor Automation: Sunil On Demand Architecture Review
| yamlSource: boolean, | ||
| ): string | Record<string, unknown> { | ||
| if (yamlSource) { | ||
| return YAML.stringify(patchedDoc, { lineWidth: 0 }); |
There was a problem hiding this comment.
This patch path always parses then stringifies YAML, so a single field update rewrites the full YAML text and strips comments/original formatting. For Git-backed pipelines/templates/input sets that breaks the repo's Minimal Impact goal and can silently drop human-authored inline docs. Can we preserve the original YAML structure/text for untouched nodes, or explicitly scope the feature away from comment-preserving edits?
| params?: Record<string, unknown>; | ||
| } | ||
|
|
||
| const jsonPointerSchema = z.string().describe("JSON Pointer (RFC 6901) to the target location, e.g. /pipeline/stages/0/stage/spec/execution/steps/0/step/spec/command"); |
There was a problem hiding this comment.
jsonPointerSchema is only z.string(), and from below is also an unconstrained string. That means malformed pointers like pipeline/name pass the registered MCP schema and only fail later inside fast-json-patch after the GET/apply path. For this repo's fail-loud / fully-typed-input standard, I'd validate RFC 6901 syntax here and reuse the same schema for from.
| format: "RFC 6902 JSON Patch", | ||
| bodyKind: def.patchSupport.kind, | ||
| bodyFields: [...def.patchSupport.bodyFields], | ||
| dryRun: true, |
There was a problem hiding this comment.
patchSupport is part of the structured harness_describe surface, but this advertises dryRun while the actual public tool input is dry_run. Agents following discovery metadata will synthesize the wrong key here, so the metadata should match the runtime contract exactly (or expose both if an alias is intentional).
There was a problem hiding this comment.
Stale comment
src/tools/harness-update.tsstill has one remaining fail-loud gap:operationsaccepts an empty array, soharness_update(..., operations=[])gets past handler validation and only errors later insideapplyJsonPatch()after the confirmation/GET path. That misses the repo's "validate before dispatch" standard for the new patch mode.Assumptions:
- Re-reviewed the current head (
38f2a28a), not the earlier failing snapshot.- The earlier JSON-schema/startup regression is fixed on this head and the fresh smoke tests are green.
Change summary:
- The latest fixes brought the PR much closer to Sunil's architecture standards: patch capability is now exposed through
harness_describe, op-specific required fields are validated before dispatch, and the MCP schema is exportable again.Sent by Cursor Automation: Sunil On Demand Architecture Review
| }), | ||
| ]); | ||
|
|
||
| const patchOperationsSchema = z.array(patchOperationSchema).max(100); |
There was a problem hiding this comment.
patchOperationsSchema still allows operations: [], so an empty patch survives handler validation and only dies later in applyJsonPatch() after confirmation + the GET round trip. Since this PR explicitly moved malformed-op checks to the tool boundary, I'd either add .min(1) here or treat an empty array as an explicit no-op so the tool fails loud before dispatch.
There was a problem hiding this comment.
Stale comment
Findings
- Important:
harness_describenow exposespatchSupportusing internal/camelCase metadata instead of the publicharness_updatecontract. In particular, it advertisesdryRuninstead ofdry_run, and it forwards rawbodyFieldsvalues that can include extractor-only names such asyamlforpipeline_v1. That creates a discovery/runtime mismatch for agents.- Important: the JSON Patch path for YAML-backed resources reparses and re-stringifies the entire document, so even a one-field patch rewrites untouched YAML text. For Git-backed pipelines/templates/input sets, that will drop comments and normalize unrelated formatting, which conflicts with the repo's
Minimal Impactguidance for targeted edits.Open Questions / Assumptions
- Assuming
harness_describeis intended to be the source of truth for public tool argument names, not internal projector fields.- Assuming patch mode is expected to preserve human-authored YAML comments/formatting for Git-backed resources. If canonical rewrites are explicitly acceptable, the second finding becomes a design trade-off rather than a blocker.
Change Summary
- Adds RFC 6902 support to
harness_update, registrypatchSupportmetadata, and focused handler/utility tests.Sent by Cursor Automation: Sunil On Demand Architecture Review
| input: "operations", | ||
| format: "RFC 6902 JSON Patch", | ||
| bodyKind: def.patchSupport.kind, | ||
| bodyFields: [...def.patchSupport.bodyFields], | ||
| dryRun: true, |
There was a problem hiding this comment.
Because harness_describe now emits this object verbatim, it needs to match the public harness_update contract rather than raw projector metadata. Two mismatches here:
- the preview flag is
dry_run, notdryRun bodyFieldscan leak extractor-only names; for examplepipeline_v1now advertisesyaml, but the public update path only acceptspipeline_yaml/yamlPipeline
As written, discovery can teach agents calls that the handler will not honor.
| const parsed = YAML.parse(yamlStr); | ||
| if (parsed && typeof parsed === "object") { | ||
| return { document: parsed as Record<string, unknown>, yamlSource: true, metadata }; | ||
| } | ||
| throw new Error(`Parsed YAML for "${resourceType}" is not an object.`); | ||
| } | ||
| throw new Error( | ||
| `GET response for "${resourceType}" does not contain a YAML body (checked: ${bodyFields.join(", ")}). ` + | ||
| `Ensure the GET returns the full resource definition.`, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Serialize a patched document back to the format expected by the update endpoint. | ||
| * For YAML resources: converts back to a YAML string. | ||
| * For others: returns the JSON object directly. | ||
| */ | ||
| export function serializeBody( | ||
| patchedDoc: Record<string, unknown>, | ||
| yamlSource: boolean, | ||
| ): string | Record<string, unknown> { | ||
| if (yamlSource) { | ||
| return YAML.stringify(patchedDoc, { lineWidth: 0 }); |
There was a problem hiding this comment.
This implementation cannot preserve untouched YAML text: extractMutableBody() parses the source into a plain JS object, and serializeBody() always emits a fresh YAML.stringify(...). For Git-backed pipelines/templates/input sets, that means a one-field patch will still drop comments and normalize unrelated formatting/quoting across the file.
That seems at odds with the repo's Minimal Impact standard for targeted edits. If canonical rewrites are acceptable, I'd expect that trade-off to be explicit in the contract/tests; otherwise this likely needs a text- or AST-preserving update path.
- Resolve resource_id from url when omitted (mirror harness_get) - Correct patchSupport bodyFields for input_set/template/template_v1 and support nested YAML paths (e.g. template.yaml) - Patch mode no longer auto-derives git context; callers supply it via params just like a full-body update (no hidden behavior) - Document REMOTE git params requirement in the operations description - Reject full-body dry_run and empty operation arrays - Expose public-only patch contract in describe metadata Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Stale comment
Found 1 architecture issue on the latest PR head (
265505a4002817b77833adf3a4801bd104efe59e).
- Medium: JSON Patch rewrites the full YAML document and drops untouched comments/formatting on REMOTE resources. The implementation parses the current YAML into an object and reserializes it with
YAML.stringify(...)before callingupdate, so a targeted patch can still rewrite the whole file and remove user-authored annotations. That conflicts with the repo'sMinimal Impactguidance, especially because this PR explicitly documents/supports git-backed patch flows.Assumption: I'm treating comments and untouched YAML formatting in git-backed resources as user-visible data that should not be discarded by a targeted edit mode.
Verification:
gh pr checks 288 --json name,bucket,state,workflow,linkshows all CI jobs passing on this head.- Local repro with the same
yaml@2.8.3dependency used by the PR:YAML.parse()followed byYAML.stringify({ lineWidth: 0 })removes comments entirely, matching the behavior in this implementation.I did not find additional architecture-standard violations in the updated metadata/validation path after the follow-up fixes.
Sent by Cursor Automation: Sunil On Demand Architecture Review
| const parsed = YAML.parse(yamlStr); | ||
| if (parsed && typeof parsed === "object") { | ||
| return { document: parsed as Record<string, unknown>, yamlSource: true }; | ||
| } | ||
| throw new Error(`Parsed YAML for "${resourceType}" is not an object.`); | ||
| } | ||
| throw new Error( | ||
| `GET response for "${resourceType}" does not contain a YAML body (checked: ${bodyFields.join(", ")}). ` + | ||
| `Ensure the GET returns the full resource definition.`, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Serialize a patched document back to the format expected by the update endpoint. | ||
| * For YAML resources: converts back to a YAML string. | ||
| * For others: returns the JSON object directly. | ||
| */ | ||
| export function serializeBody( | ||
| patchedDoc: Record<string, unknown>, | ||
| yamlSource: boolean, | ||
| ): string | Record<string, unknown> { | ||
| if (yamlSource) { | ||
| return YAML.stringify(patchedDoc, { lineWidth: 0 }); |
There was a problem hiding this comment.
This parse -> patch -> YAML.stringify() round-trip means operations mode rewrites the entire YAML text, not just the targeted fields. For REMOTE/git-backed resources that the PR explicitly supports, that will drop comments and normalize untouched formatting/quoting in the committed file. That feels out of bounds for the repo's Minimal Impact standard: a token-efficient targeted edit can still create a broad textual diff and lose human-authored annotations.
Can we either preserve the YAML document/CST here, or fail closed for Git-backed resources until we can patch without rewriting unrelated text?
There was a problem hiding this comment.
I think there is no way to add comments in harness pipeline yaml. It tried via UI and git and next update from UI removes the comments
cc: @thisrohangupta
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # package.json # src/tools/harness-update.ts # tests/tools/tool-handlers.test.ts
There was a problem hiding this comment.
Found 2 actionable issues in the new JSON Patch update path.
Focused verification on the PR head:
pnpm test tests/utils/json-patch.test.ts tests/tools/tool-handlers.test.tspnpm typecheck
Those suites are green, but they do not cover the two edge cases below.
Sent by Cursor Automation: Sunil On Demand Architecture Review
| ? identFields[identFields.length - 1]! | ||
| : identFields[0]; | ||
| if (primaryField && args.resource_id) { | ||
| input[primaryField] = args.resource_id; |
There was a problem hiding this comment.
Medium – patch mode stops failing loudly on primary-ID conflicts
handleFullBodyUpdate() still rejects resource_id/URL-vs-params.<primaryId> mismatches, but patch mode now merges params first and then unconditionally overwrites the primary field here. Because URL IDs are normalized into args.resource_id earlier, a call like resource_id="pipe-from-id" plus params.pipeline_id="pipe-from-params" succeeds against pipe-from-id instead of returning the existing Conflicting identifiers error.
I reproduced this on the built PR head: the handler fetched and updated pipe-from-id while silently discarding the conflicting params.pipeline_id. That can patch the wrong resource while hiding a bad agent call.
| const elicit = await confirmViaElicitation({ | ||
| server, | ||
| toolName: "harness_update", | ||
| message: `Apply ${operations.length} JSON Patch operation(s) to ${args.resource_type} "${args.resource_id}"?\n\n${opsPreview}`, |
There was a problem hiding this comment.
Low – the params-only ID path leaks undefined into confirmation/audit metadata
This prompt interpolates args.resource_id before the primary identifier is resolved from params, so a supported call like harness_update({ resource_type: "pipeline", params: { pipeline_id: "my-pipe" }, operations: [...] }) asks to patch pipeline "undefined" even though the request succeeds.
The same unresolved value is later passed into the update audit context, so the params-only path is not wired cleanly end-to-end through the user-facing prompt and audit metadata.
There was a problem hiding this comment.
Found 2 issues against the harness_update contract in the new JSON Patch flow.
- Medium: patch mode no longer fails loudly on conflicting primary identifiers the way full-body update mode does.
- Low: params-only patch calls lose the resolved resource ID in the confirmation/audit path.
Validation run locally:
pnpm typecheckpnpm test -- tests/utils/json-patch.test.ts tests/tools/tool-handlers.test.ts(this executed the full suite locally: 1604 tests passed)
Sent by Cursor Automation: Sunil On Demand Architecture Review
| const getInput = applyUrlDefaults({ ...rest } as Record<string, unknown>, args.url); | ||
| const coercedParams = coerceRecord(params); | ||
| if (coercedParams) Object.assign(getInput, coercedParams); | ||
| applyUpdateInputDefaults(getInput, def, args); |
There was a problem hiding this comment.
handleFullBodyUpdate() explicitly rejects a mismatch between resource_id/URL and params.<primary-id>, but patch mode doesn't do that anymore. Here we merge params, then applyUpdateInputDefaults() silently overwrites the primary identifier from args.resource_id instead of surfacing a conflict. That violates the repo's "fail loudly" rule and makes caller mistakes hard to spot. Please run the same conflict check in patch mode before the GET so conflicting identifiers fail instead of being silently chosen.
| const elicit = await confirmViaElicitation({ | ||
| server, | ||
| toolName: "harness_update", | ||
| message: `Apply ${operations.length} JSON Patch operation(s) to ${args.resource_type} "${args.resource_id}"?\n\n${opsPreview}`, |
There was a problem hiding this comment.
Patch mode never resolves the effective primary ID before this prompt, so callers that use the documented params-only identifier flow get "undefined" in confirmation text. The unresolved args.resource_id is also what gets passed into the later audit metadata, so those requests lose the resource identifier in logs as well. Please resolve the ID from params first, the same way full-body mode does, and reuse that resolved value for both confirmation and audit dispatch.
…odes Extract a shared resolveUpdateIdentifier() (plus applyVersionLabelDefault) used by both handlePatchUpdate and handleFullBodyUpdate so the two paths cannot drift. JSON Patch mode now fails loudly on conflicting primary identifiers and surfaces the resolved resource id in the confirmation and audit path (previously it could log "undefined" for params-only calls). Adds regression tests for both behaviors in patch mode. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
1 Important finding:
- Patch mode can replace the whole resource with a scalar/array because root JSON Pointers are allowed and the patched document is never validated back to object shape before serialization. That weakens the new write-safety contract for
harness_updateoperations mode.
Sent by Cursor Automation: Sunil On Demand Architecture Review
| const getResult = await registry.dispatch(client, args.resource_type, "get", getInput); | ||
| const { document, yamlSource } = extractMutableBody(getResult, def); | ||
|
|
||
| const patched = applyJsonPatch(document, operations); |
There was a problem hiding this comment.
Important: RFC 6901 allows path: "" to target the document root, and fast-json-patch accepts root replacement. Because we never validate patched after applyJsonPatch(), an operation like { op: "replace", path: "", value: 1 } will serialize to scalar YAML and get forwarded to update, turning a supposedly targeted edit into an unsafe full-body write. Please reject root-level replacement/removal or enforce that the patched result remains a non-array object before serializeBody(). There’s also no regression test covering this edge case.
There was a problem hiding this comment.
Addressed this in the latest commit
…-object
RFC 6901 allows path "" to target the document root and fast-json-patch
honors root-level replace/remove, so an op like {op:"replace",path:"",value:1}
could collapse the whole resource into a scalar, array, or null. That value
flowed through serializeBody() and got forwarded as an unsafe full-body
overwrite. applyJsonPatch() now validates the patched result and fails loudly
unless it remains a non-array object, closing the hole for both dry-run and
live update paths.
Adds regression tests for scalar/array/null root results and the valid
object root replacement case.
Co-authored-by: Cursor <cursoragent@cursor.com>


Description
Adds a second mode to
harness_update: passoperations(RFC 6902 JSON Patch) to change specific fields without resending the full resource. The tool fetches the current resource, applies the patch server-side, and sends the merged result.body(full replacement) andoperationsare mutually exclusive;dry_runpreviews the diff without writing.This is similar to kubectl patch (one of the supported modes):
Patch mode is intentionally limited to YAML-backed resources whose registry metadata declares safe mutable-body extraction:
pipeline,pipeline_v1,input_set,template, andtemplate_v1. Unsupported non-YAML resources are rejected because many read shapes differ from write shapes. Git-backed metadata such aslastObjectId,lastCommitId, andstoreTypeis preserved for the update call.Why — token efficiency
Full-body updates force the agent to regenerate the entire resource on every edit, which consumes a lot of output tokens. With this PR, the agent reads once and can patch with minimal output tokens.
Measured on a 526-line pipeline, a multi-part edit:
~11x less generated text for a single edit, and the gap widens with each additional edit since patch size stays flat while full-body cost scales with resource size.
Example
{ "resource_type": "pipeline", "resource_id": "ces_build_lib", "url": "https://app.harness.io/ng/account/SL32ke39QeKMAVwtGMAgow/all/orgs/default/projects/revanthcstesting/pipelines/ces_build_lib/pipeline-studio/?storeType=INLINE", "confirm": true, "operations": [ { "op": "replace", "path": "/pipeline/stages/0/stage/spec/execution/steps/0/step/spec/command", "value": "PROJECT_VERSION=$(./mvnw --batch-mode -q help:evaluate -Dexpression=project.version -DforceStdout)\nexport PROJECT_VERSION\necho \"Project Version: ${PROJECT_VERSION}\"\n" }, { "op": "add", "path": "/pipeline/stages/0/stage/spec/execution/steps/0/step/spec/outputVariables/3", "value": { "name": "DOCKER_IMAGE_FULL" } }, { "op": "replace", "path": "/pipeline/stages/0/stage/spec/execution/steps/3/parallel/0/stepGroup/steps/0/step/spec/command", "value": "echo \"Starting Maven Build...\"\n./mvnw --batch-mode clean compile -DskipTests\n" } ] }Array paths use numeric indices per RFC 6901; to guard against index drift, precede a
replace/removeon an array element with atestop asserting that element's identifier.Type of Change
Checklist