Repository navigation
[AI-175] Result pane endpoints - #26138
jonasjabari wants to merge 9 commits into
Conversation
Routes under /ai/text_transform_panes for starting, polling, closing, applying and cancelling a result pane run. The contract validates the request id (UUID v4), scope and context ids every pane request carries. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The create action resolves the context, starts a run (or repeats one of the user's own runs) and appends the pane to the body. Unavailable actions render the pane as failed; invalid parameters answer 400. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
show answers polls with updated body and footer sections (204 while nothing changed), destroy cancels the run and removes the pane, apply dispatches the completed text to the editor and cancel stops a run whose pane disappeared. All lookups are scoped to the user's own runs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Validate action_id, input and retry_of in the contract, reject a work package combined with a project and a project without a type (like the API v3 run endpoint), and accept blank context ids. Only failed runs can be retried. Polls resolve the context only when they render, the pane append renders lazily like the other turbo stream helpers, and new events are detected through TextTransformRun#events_after. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Panes for a new work package have no work package to derive the project from, so project-relative links such as wiki links did not resolve. The pane state now carries the context's project. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Caution The provided work package version does not match the core version Details:
Please make sure that:
|
|
|
||
| def output | ||
| helpers.format_text(pane.text, object: pane.work_package) | ||
| helpers.format_text(pane.text, object: pane.work_package, project: pane.project) |
There was a problem hiding this comment.
🍊 This renders model output through the full markdown pipeline while it is still streaming, and the default CSP allows images from any host. Text that reaches the model can carry an injection (a comment someone else wrote, a type template), and the model can answer with  carrying a fragment of the input. The browser fetches that before the user has reviewed anything.
Shall we strip images (or at least non-same-origin ones) and macros when rendering the pane? The <template> apply text can stay as is. Not verified end to end that format_text keeps external <img>; worth a quick console check.
There was a problem hiding this comment.
@jonasjabari this sounds legit, have we already considered prompt injection mitigations?
There was a problem hiding this comment.
Good catch, and confirmed: csp_img_src defaults to * and the sanitizer keeps img[src]. No dedicated injection mitigation existed before this. Fixed in e29a2c5: new opt-in UntrustedSourceFilter via format_text(untrusted_source: true). Images become links ("Image: alt") and macros are dropped, so nothing is fetched before the user reviews it. The raw <template> text stays as is.
| before_action :require_login | ||
| before_action :require_ai_text_transform_actions_feature | ||
| authorization_checked! :create, :show, :destroy, :apply, :cancel | ||
| before_action :validate_pane_params, except: :cancel |
There was a problem hiding this comment.
🍊 destroy runs the full contract too. A close request with a missing or bad request_id or scope gets a 400 and the run is never cancelled, so the LLM keeps going. Closing is the one request that should always get through.
The run stores no request_id, scope or context, so the client has to echo all of them on every poll, close and apply, and uuid plus request_id become two identities for one thing. Persisting them on the run at create would let show, destroy and apply work from the uuid alone and limit the contract to create. Fine as a follow-up; for now, validating only request_id on destroy would close the gap.
There was a problem hiding this comment.
Done in a4c5059: destroy skips the contract, always cancels and removes the pane, and dispatches closed only with a valid request_id. Persisting request_id, scope and context on the run is in AI-198.
| end | ||
|
|
||
| def request_cancel(cancellable) | ||
| cancellable.update!(cancel_requested: true) unless cancellable.terminal? |
There was a problem hiding this comment.
🍊 Same line as the API v3 cancel. Shall we move it onto the model so both call sites share it?
# AI::TextTransformRun
def request_cancel!
update_column(:cancel_requested, true) unless terminal?
endupdate! also runs the full validations here, so an odd run record (long input, missing system prompt) turns a close into a 500.
There was a problem hiding this comment.
Done in 6254eca with update_column; the API cancel uses it too. Cancelling queued runs before the LLM call is in AI-198.
| end | ||
|
|
||
| def apply | ||
| return head(:unprocessable_entity) unless run.succeeded? |
There was a problem hiding this comment.
🍊 show re-resolves the context and gives 403 once edit permission is gone, but apply does not, so the text can still be applied into a work package the user can no longer edit. Either before_action :resolve_context, only: %i[create apply] with show reusing it, or a note that the client owns this check.
There was a problem hiding this comment.
Done: apply resolves the context as well (403 once the edit permission is gone).
| before_action :resolve_context, only: :create | ||
|
|
||
| def show | ||
| return head(:no_content) unless run.terminal? || new_events? |
There was a problem hiding this comment.
🍊 A terminal run never answers 204, so every poll after completion loads all events, re-joins the text, runs format_text and resolves the context again. While generating, each updating poll re-renders the full text too, so the work grows quadratically with output length, with no rate limit on the endpoint.
Shall we 204 when after >= last_seq for terminal runs as well, and pin in the AI-176 contract that the client stops polling on done, failed, stopped and cancelled? A Cache-Control: no-store on the 204 would not hurt either.
There was a problem hiding this comment.
Done slightly differently. A plain after >= last_seq would strand a pane, because completed is written before the status flips and cancelled writes no event. Polls now send the state they show, and the server answers 204 while events and state are unchanged, also for finished runs. Stopping on terminal states is pinned in the AI-176 contract. Throttling is in AI-198.
| include AI::TextTransformActionsFeature | ||
|
|
||
| APPLY_EVENT = "op-dispatched:ai-text-transform:apply" | ||
| CLOSED_EVENT = "op-dispatched:ai-text-transform:closed" |
There was a problem hiding this comment.
🍏 apply is imperative and closed a past participle. Worth settling before AI-176 ships, since renaming later breaks the client.
There was a problem hiding this comment.
Renamed to op-dispatched:ai-text-transform:accepted. The other dispatched events all report what happened (changed, updated, moved), and at that point the text is not applied yet. The route stays apply.
| not_unique: "must be unique within the same hierarchy level." | ||
| parent: | ||
| not_descendant: "must be a descendant of the hierarchy root." | ||
| project_id: |
There was a problem hiding this comment.
🍏 These are scoped by field name only, so any later contract with a project_id or type_id rule collides. Namespacing under the contract would avoid that.
There was a problem hiding this comment.
Done: namespaced under op_dry_validation.errors.ai_text_transform_pane.
| expect(response).to have_http_status(:not_found) | ||
| end | ||
|
|
||
| it "rejects a request id that is not a UUID" do |
There was a problem hiding this comment.
🍏 These six 400 variants are near-identical. A contract spec (or one table-driven example) plus a single 400 request example would say the same with less.
Not covered yet: flag off for show, apply, cancel and destroy; unauthenticated; retry_of pointing at a run that is not failed; a type from a different project; apply text containing <, quotes and </template>.
There was a problem hiding this comment.
Done: a table-driven contract spec and one 400 request example, plus the missing cases (flag off for every action, anonymous user, retry of a non-failed run, a type outside the project, markup in the text).
| end | ||
|
|
||
| describe "POST apply" do | ||
| it "hands the completed text to the editor" do |
There was a problem hiding this comment.
🍏 A text param sent by the client and ignored would pin the "never from the client" claim.
| before { run.events.build(kind: "text_delta", payload: { "delta" => " [[Release notes]]" }, seq: 2) } | ||
|
|
||
| it "resolves wiki links within the project" do | ||
| expect(subject).to have_css("a[href^='/projects/#{project.identifier}/wiki/release-notes']", text: "Release notes") |
There was a problem hiding this comment.
🍏 have_link("Release notes", href: ...) matches the selector conventions better than the css prefix.
There was a problem hiding this comment.
Done, with a regex href (the link carries ?title=).
TextTransformRun#request_cancel! replaces the inline update in the API cancel endpoint. It writes the flag with update_column, so closing a pane cannot fail on validations of an otherwise unchanged run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pane renders model output while it streams, before the user has reviewed it. An image in that output would be fetched right away and could carry parts of the input to a foreign host (prompt injection via a template or someone else's text). format_text(untrusted_source: true) now turns images into links and drops macros; the raw text kept for Replace is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
For elements that live outside the page layout, like the AI result pane. Renders lazily like the other turbo stream helpers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- destroy always cancels and removes the pane; closed is dispatched only with a valid request id - apply re-checks the context like polls do - polls may send the state they show and get 204 while it and the events are unchanged, also for finished runs - not found and forbidden answer without an HTML body - the contract rejects retry_of together with an action or input, limits input to the run's maximum, lists its keys literally and namespaces its messages - the pane state takes the resolved Context instead of single ids - the apply event is renamed to accepted, in line with the other op-dispatched events - a table-driven contract spec replaces the separate 400 examples; new request examples for the feature flag on every action, an anonymous user, a type outside the project, markup in the text and a text sent by the client Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ticket
https://community.openproject.org/wp/AI-175
What are you trying to accomplish?
Server side of the AI result pane (AI-126), part 1 of AI-175. A new Turbo controller starts a text transform run, appends the pane to
<body>, answers polls with formatted progress and hands the finished text back to the editor. Everything is behind theai_text_transform_actionsfeature flag; there is no client code yet (AI-176).POST /ai/text_transform_panes: start a run (or retry a failed one) and append the paneGET …/:uuid?after=&state=: poll. 204 while the events and the state the client shows are unchanged, otherwise updates the body and footerDELETE …/:uuid: close the pane, cancel the run, dispatchclosed; always gets through, even with bad paramsPOST …/:uuid/apply: dispatchop-dispatched:ai-text-transform:acceptedwith the completed text (taken from the run, never from the client)POST …/:uuid/cancel: stop a run whose pane disappeared (fornavigator.sendBeacon/keepaliveon page leave, AI-176)What approach did you choose and why?
ResolveContext(AI-174), on create, apply and rendering polls. Run lookups are scoped to the current user's runs, so another user's run is a 404.format_text(untrusted_source: true): images become links and macros are dropped, so nothing is fetched before the user reviews it.DryApplicationContractvalidatesrequest_id(UUID v4),scopeand the context ids, with the same combination rules as the API v3 run endpoint. Invalid input gets a 400.TextTransformRun#request_cancel!(also used by the API cancel) andOpTurbo::ComponentStream#append_to_body_via_turbo_stream.AI involvement
Collaborative – AI generated a substantial part of the code; I reviewed and understand every line.
Merge checklist
🤖 Generated with Claude Code