Skip to content

feat: run a pipeline inside a project - #2754

Draft
camielvs wants to merge 6 commits into
09-18-feat_local_pipelines_in_projectsfrom
09-18-feat_run_a_pipeline_in_a_project
Draft

camielvs wants to merge 6 commits into
09-18-feat_local_pipelines_in_projectsfrom
09-18-feat_run_a_pipeline_in_a_project

Conversation

@camielvs

@camielvs camielvs commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Description

A project can hold pipelines and it can list runs, but nothing in the app could make a run that belongs to a project — the run feed was read-only, and the one run attached to a project on the dev backend had been put there by something other than this app.

The association already exists in the backend's read path: a run belongs to a project when it carries the run-level annotation tangleml.com/project/project-id/<project_id>. So this is not a new data model, it is the missing write path.

All three ways in are here: from the project page, from the editor, and by rerunning a run that already belonged to one.

Memberships are a set, not a field

The project id lives in the annotation's key, with a constant value. That makes these annotations a set of memberships: a run can belong to several projects at once. The data layer is plural (projectIds) for that reason, even though each way of starting a run picks one — which also means a rerun can carry a multi-project run's attribution across without dropping any.

Attribution is write-once

PUT /api/pipeline_runs/{id}/annotations/{key} takes the key as a path segment, and 404s on this key — URL-encoded or raw — while a slash-free key on the same run returns 200. Probed both ways.

So there is no repairing a run that was submitted unattributed, and no revoking one that was attributed wrongly. Attribution has to be right at submission. That prices every "the project id is quietly dropped" path as permanent, and is why the remaining cases validate the id where it comes from somewhere untrusted, and keep it verbatim where it is a historical fact.

Where the project comes from

Every run in the app is created through one function, submitPipelineRun, so the project travels as one option on it and all three entry points share it. What differs is only where the id comes from.

From the project page, it is definitional — you pressed the button on a resource row of that project, so there is nothing to validate. The preview already holds the parsed spec for a pipeline the backend holds and for one held in this browser, so both can be run, and the existing argument dialog is reused as-is rather than growing a second arguments UI.

In the editor, it lives in the ?projectId= search param rather than in component state, for two reasons. A reload or a duplicated tab keeps it, which is what "the runs of this tab" has to mean; and the chip that names it is nowhere near the submitter that acts on it — in the v2 editor the submitter is sr-only — so both read one value instead of keeping two copies that can disagree. The dialog's project field writes that same param, so the field and the chip are two views of one answer. An id off a link or a bookmark is only acted on once a project answers to it: a project that is merely slow, or a backend failing for some other reason, keeps the attribution, and only a project that is gone drops it. With the flag off the param is ignored altogether, so a shared url cannot attribute runs in a UI that never mentioned a project.

On a rerun, it is read from the run being rerun, and every project of it rather than one. The read happens inside the mutation, not on render: a click that beat it would submit a copy belonging nowhere. The read is also allowed to fail the rerun, since a refused rerun is something you can simply do again, where losing the attribution is permanent. Nothing is asked of the backend when projects are off, so reruns elsewhere in the app cannot fail this new way.

Which projects hold a pipeline has no endpoint to ask — and resources cannot be filtered by what they point at — so it is one resource listing per project, read through the very query the project page already uses. No second cache to invalidate: a pipeline just added to a project is offered immediately.

Also here

  • A project's run feed is invalidated when a run starts in it. Nothing invalidated that key before; it is 5-minute-stale, so a run you had just started appeared to have gone nowhere. The invalidation is deliberately narrow — ["projects", id, "runs"] — because the resource mutations document why the loose ["projects", id] prefix must not be invalidated, and a test pins that.
  • The offer to copy arguments from a past run is withheld for a backend pipeline. Those runs are found by the pipeline's name in this browser, so for a pipeline the backend holds they would belong to a different pipeline that merely shares its name — the same confusion the sibling PR just fixed for the editor link, three lines above in the same file.
  • A pipeline that does not validate is not offered at all, rather than submitted to fail.
  • The post-submit notes/tags writes are now shared rather than private to the editor's submitter, so notes typed on the project page are kept instead of silently dropped.
  • The same run's annotations were cached under three different keys, one of them varying by whether the id arrived as a string or a number, so the same data was fetched repeatedly and never shared. They now go through one set of query options — which is also why a rerun usually finds them already loaded.
  • One import fixed so the "leaf utils import annotation keys from the keys module" rule is true rather than nearly-true.

Verification

npm run validate and the full suite green — 2815 tests. Among them: that a submission with no project is byte-identical to today's (every run in the app goes through this path, so that is a regression guard rather than a feature test); that a digits-only project id arriving as a number is still read as a string, since the router JSON-parses search params; and that a junk param attributes nothing rather than inventing a project.

Not yet verified in a browser. The end-to-end claim that matters — that the backend accepts the annotation we write at creation and then returns the run in the project's feed — has only unit coverage so far. The script for it is written and waiting; the dev backend was unavailable when it came time to run it. Worth doing before this merges.

Known limitations

  • Run status does not advance on the project page. Each row's status is a separate query with no refetch interval, so a run submitted here shows its opening status and then sits there until the page is remounted. Pre-existing, but this PR is what makes it visible, and it is worth fixing next.
  • Running a pipeline into a project does not add the pipeline to that project. The field only offers projects that already hold it.
  • Membership is computed client-side and reads only the first page of projects, and of each project's resources. Cheap at today's scale, but it grows with a reader's project count rather than with anything about the pipeline. A ?pipeline_id= filter on the projects list would remove the whole scan — worth raising with the backend team.
  • A pipeline renamed without ever having a registry id is not recognised as belonging to its projects, so it is missing from the field. Same limit the preview already documents, and it fails by omission rather than by pointing at the wrong pipeline.
  • Notes and tags are still written as two fire-and-forget requests after the run exists, neither with an error handler, so a failed write loses what was typed. They would be better carried in the submission body beside the project annotation. Left alone here: changing how every run in the app records its notes does not belong in a projects PR.

🤖 Generated with Claude Code

@camielvs
camielvs requested a review from a team as a code owner September 18, 2026 20:20
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

🎩 Preview

A preview build has been created at: 09-18-feat_run_a_pipeline_in_a_project/96a6606

@camielvs
camielvs force-pushed the 09-18-feat_local_pipelines_in_projects branch from 694dfab to b3ac3e8 Compare September 18, 2026 21:19
@camielvs
camielvs force-pushed the 09-18-feat_run_a_pipeline_in_a_project branch from ca51a8c to 39a3270 Compare September 18, 2026 21:19
@camielvs
camielvs force-pushed the 09-18-feat_local_pipelines_in_projects branch from b3ac3e8 to b59af04 Compare September 18, 2026 21:26
@camielvs
camielvs force-pushed the 09-18-feat_run_a_pipeline_in_a_project branch from 39a3270 to 3771df4 Compare September 18, 2026 21:26
@camielvs
camielvs force-pushed the 09-18-feat_local_pipelines_in_projects branch from b59af04 to 3689e2f Compare September 18, 2026 21:45
@camielvs
camielvs force-pushed the 09-18-feat_run_a_pipeline_in_a_project branch from 3771df4 to 3725bff Compare September 18, 2026 21:45
@camielvs
camielvs force-pushed the 09-18-feat_local_pipelines_in_projects branch from 3689e2f to 53a3efb Compare September 18, 2026 22:09
@camielvs
camielvs force-pushed the 09-18-feat_run_a_pipeline_in_a_project branch from 3725bff to 0d1309e Compare September 18, 2026 22:09
@camielvs camielvs mentioned this pull request Sep 18, 2026

camielvs commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@camielvs
camielvs force-pushed the 09-18-feat_run_a_pipeline_in_a_project branch from 0d1309e to e369ae1 Compare September 23, 2026 20:57
@camielvs
camielvs force-pushed the 09-18-feat_local_pipelines_in_projects branch from 53a3efb to 826eab1 Compare September 23, 2026 20:57
@camielvs
camielvs force-pushed the 09-18-feat_local_pipelines_in_projects branch from 826eab1 to 17d4348 Compare September 23, 2026 23:00
@camielvs
camielvs force-pushed the 09-18-feat_run_a_pipeline_in_a_project branch 2 times, most recently from 0099091 to fc948ad Compare September 23, 2026 23:36
@camielvs
camielvs force-pushed the 09-18-feat_local_pipelines_in_projects branch from 17d4348 to d1aef85 Compare September 23, 2026 23:36
@camielvs
camielvs force-pushed the 09-18-feat_run_a_pipeline_in_a_project branch from fc948ad to b5bacfe Compare September 23, 2026 23:57
@camielvs
camielvs force-pushed the 09-18-feat_local_pipelines_in_projects branch from d1aef85 to cddb017 Compare September 23, 2026 23:57
camielvs and others added 6 commits September 23, 2026 17:07
A run belongs to a project by carrying one annotation per project, the
id in the key and a constant value — so these are a set of memberships,
and a run can sit in several projects at once. The data layer is plural
for that reason, even though each way of starting a run picks one.

Attribution has to be right at submission. The endpoint that sets a
single annotation afterwards takes the key as a path segment and rejects
this one for its slashes, encoded or raw, so there is no repairing a run
that was submitted unattributed.

Every run in the app is created through one function, so the project
travels as one option on it. Nothing passes it yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mutation was private to the editor's submitter, which is the only
place a run could be started from. Moved out as it was, with two
additions: the project a run belongs to, and the invalidation that makes
a project's run feed notice it — otherwise the feed is stale for five
minutes and a run you just started appears to have gone nowhere.

Behaviour is unchanged for the editor, which passes no project.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Picking a pipeline in a project now offers to run it, in the project's
own context: the run carries the project, so it lands in the feed on the
same page. It works for a pipeline the backend holds and for one held in
this browser, since the preview already has the parsed spec of either.

The arguments dialog is reused as it is, so a pipeline with required
inputs is asked about exactly as it would be in the editor — except for
the offer to copy arguments from a past run, which is withheld for a
backend pipeline. Those runs are found by name in this browser, so for a
pipeline the backend holds they would belong to a different pipeline
that merely shares its name.

A pipeline that does not validate is not offered at all, rather than
submitted to fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A rerun is the same pipeline run again, so it belongs where the original
belonged. Every project of it, not one: memberships are a set, and a
rerun that kept only the first would quietly drop the rest.

The projects are read at the moment of the rerun rather than on render.
A click that beat the read would submit a copy belonging nowhere, and
that cannot be corrected afterwards — so the read is also allowed to
fail the rerun, since a refused rerun is something the reader can simply
do again. Nothing is asked of the backend when projects are switched
off, so a reader not using them keeps reruns that cannot fail this way.

The same run's annotations were being cached under three different keys,
one of them varying by whether the id arrived as a string or a number,
so they are now read through one set of options. The rerun therefore
usually finds them already loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening a pipeline from a project now says so in the editor, and the
runs started there belong to that project until the reader says
otherwise.

The project lives in the url rather than in the editor's state, for two
reasons. A reload or a duplicated tab keeps it, which is what "the runs
of this tab" has to mean. And the chip that names it is nowhere near the
submitter that acts on it — in one editor the submitter is not even
visible — so both read one value instead of keeping two copies that can
disagree.

An id off a link or a bookmark is only acted on once a project answers
to it, since a run attributed to a project nobody can see cannot be
corrected. A project that is merely slow, or a backend that fails for
some other reason, keeps the attribution; only a project that is gone
drops it. With projects switched off the id is ignored altogether, so a
shared url cannot attribute runs in a UI that never mentioned a project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dialog that asks for a run's arguments now also asks which project
it belongs to, offering the projects that hold this pipeline. Choosing
one sets the same url the editor already reads, so the chip and the
field are two views of one answer rather than two answers.

Nothing answers "which projects hold this pipeline" — there is no such
endpoint, and resources cannot be filtered by what they point at — so it
is one resource listing per project. Those listings are read through the
very query the project page uses, so a pipeline just added to a project
is offered here without a second cache to invalidate.

A project's pointer records the name a pipeline had when it was added,
so a pipeline renamed since is recognised by the registry id carried
beside the name. One renamed without ever having an id cannot be
recognised, and is missing from the projects holding it — the same limit
the preview already documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camielvs
camielvs force-pushed the 09-18-feat_local_pipelines_in_projects branch from cddb017 to a25f507 Compare September 24, 2026 00:13
@camielvs
camielvs force-pushed the 09-18-feat_run_a_pipeline_in_a_project branch from b5bacfe to 96a6606 Compare September 24, 2026 00:13

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant