Skip to content

feat(workflow): serve the pinned copy to public viewers - #8575

Draft
yangzhang75 wants to merge 2 commits into
apache:mainfrom
yangzhang75:pin/3-read-paths
Draft

yangzhang75 wants to merge 2 commits into
apache:mainfrom
yangzhang75:pin/3-read-paths

Conversation

@yangzhang75

@yangzhang75 yangzhang75 commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

What changes were proposed in this PR?

With a version pinned, a workflow has two copies. This routes every read that serves a viewer
without granted access through the frozen one.

  • The seam — WorkflowAccessResource.hasGrantedAccess(wid, uid): granted access, as opposed
    to being able to read a workflow only because it is public. An owner or a shared user keeps
    tracking the author's latest, because sharing is not publishing. Everyone else is here only
    because the workflow is public, and gets the public copy.
  • One copy, not four fields — WorkflowPublishService.PublicCopy carries the name, the
    description, the content and the default view together, and publicCopyOf hands them out as a
    group, so no surface can serve the published graph under a title the author never published, or
    open a form view over a copy whose content carries no form.
  • Routed through it — opening a workflow, the hub's read of a public workflow, Clone,
    Duplicate, /workflow_name, /workflow_description, and the size a listing shows.
  • The revision history is a door into the same room. Replaying a version folds deltas back
    from the author's current content, so listing or checking out versions would hand a public
    viewer the drafts the pin is holding back. canReadHistory keeps the history open to anyone
    with granted access, and to everyone while nothing is pinned — the public copy is then the
    author's latest and its history is the history of what everyone can already see.

A workflow that follows the author's latest — every workflow today — is served exactly what it
is served now.
publicCopyOf returns the working copy when nothing is frozen, so with no UI to
pin from yet (that arrives later in the series), nothing anyone can see changes.

Any related issues, documentation, discussions?

Closes #7939
Part of #7828. Design discussion: #7128. Stacked on #7853, which this needs and which is under
review; until that merges, this PR shows its commit too and the review here is the second one.

How was this PR tested?

22 new cases in WorkflowPublishSpec (54 in the suite, 540 in the dashboard package):

  • the published version reaching a stranger while the author keeps their own working copy;
  • opening a pinned workflow serving the whole copy — its own title, description and view — and
    the author still opening everything they have;
  • a collaborator with granted read access tracking the author's latest as they keep editing;
  • clone and duplicate taking the published copy, metadata included, while a private workflow
    still clones its working copy and a copy starts with no publish state of its own;
  • the size each case reports;
  • the revision history: a public viewer is refused while a pin is in place, a collaborator is
    not, and a public workflow with nothing pinned keeps the history it has today;
  • a public copy of a workflow that is not public being refused rather than falling through.

The history guard was checked by mutation: restoring hasReadAccess turns the leak case red,
and the case was written from a reproduction — a stranger retrieving a version of a pinned
workflow and getting back an unpublished draft.

scalafmtCheckAll clean.

Was this PR authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Opus 5)

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Automated Reviewer Suggestions

Based on the git blame history of the changed files, we recommend the following reviewers:

  • Contributors with relevant context: @aglinxinyuan
    You can notify them by mentioning @aglinxinyuan in a comment.

@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Benchmark changes need a look

🟢 2 better · 🔴 7 worse · ⚪ 6 noise (<±5%) · 0 without baseline

Compared against main be65bf8 benchmarked on this same runner, so the delta is largely free of cross-runner hardware noise. The "7d avg" column still reflects the gh-pages dashboard. Treat <±5% as noise unless repeated.

Dashboard · Run

config throughput MB/s latency max Δ latest / 7d
🔴 bs=10 sw=10 sl=64 389 0.238 24,978/33,145/33,145 us 🟢 -8.5% / 🔴 +144.8%
🔴 bs=100 sw=10 sl=64 774 0.473 126,314/184,655/184,655 us 🔴 +26.8% / 🔴 +97.9%
⚪ bs=1000 sw=10 sl=64 917 0.559 1,088,461/1,192,807/1,192,807 us ⚪ within ±5% / 🔴 +32.3%
Baseline details

Latest main be65bf8 from same runner

config metric PR latest main 7d avg Δ latest Δ 7d
bs=10 sw=10 sl=64 throughput 389 tuples/sec 416 tuples/sec 983 tuples/sec -6.5% -60.4%
bs=10 sw=10 sl=64 MB/s 0.238 MB/s 0.254 MB/s 0.6 MB/s -6.3% -60.3%
bs=10 sw=10 sl=64 p50 24,978 us 24,560 us 10,903 us +1.7% +129.1%
bs=10 sw=10 sl=64 p95 33,145 us 36,228 us 13,539 us -8.5% +144.8%
bs=10 sw=10 sl=64 p99 33,145 us 36,228 us 16,174 us -8.5% +104.9%
bs=100 sw=10 sl=64 throughput 774 tuples/sec 828 tuples/sec 1,259 tuples/sec -6.5% -38.5%
bs=100 sw=10 sl=64 MB/s 0.473 MB/s 0.505 MB/s 0.769 MB/s -6.3% -38.5%
bs=100 sw=10 sl=64 p50 126,314 us 120,044 us 86,981 us +5.2% +45.2%
bs=100 sw=10 sl=64 p95 184,655 us 145,683 us 93,289 us +26.8% +97.9%
bs=100 sw=10 sl=64 p99 184,655 us 145,683 us 105,695 us +26.8% +74.7%
bs=1000 sw=10 sl=64 throughput 917 tuples/sec 919 tuples/sec 1,300 tuples/sec -0.2% -29.4%
bs=1000 sw=10 sl=64 MB/s 0.559 MB/s 0.561 MB/s 0.793 MB/s -0.4% -29.5%
bs=1000 sw=10 sl=64 p50 1,088,461 us 1,081,411 us 854,105 us +0.7% +27.4%
bs=1000 sw=10 sl=64 p95 1,192,807 us 1,141,749 us 901,686 us +4.5% +32.3%
bs=1000 sw=10 sl=64 p99 1,192,807 us 1,141,749 us 924,532 us +4.5% +29.0%
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,513.70,200,128000,389,0.238,24978.46,33145.44,33145.44
1,100,10,64,20,2582.83,2000,1280000,774,0.473,126314.45,184655.31,184655.31
2,1000,10,64,20,21819.62,20000,12800000,917,0.559,1088460.97,1192806.74,1192806.74

@codecov-commenter

codecov-commenter commented Sep 17, 2026 •

Copy link
Copy Markdown

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
56 2 54 1
View the top 1 failed test(s) by shortest run time
org.apache.texera.amber.engine.e2e.LoopIntegrationSpec::Engine should bind the inner loop's variable where it shadows the outer loop's of the same name
Stack Traces | 181s run time
com.twitter.util.TimeoutException: 1.minutes+30.seconds
	at com.twitter.util.Promise.ready(Promise.scala:680)
	at com.twitter.util.Promise.result(Promise.scala:689)
	at com.twitter.util.Await$.$anonfun$result$1(Awaitable.scala:155)
	at com.twitter.concurrent.LocalScheduler$Activation.blocking(Scheduler.scala:189)
	at com.twitter.concurrent.LocalScheduler.blocking(Scheduler.scala:256)
	at com.twitter.concurrent.Scheduler$.blocking(Scheduler.scala:85)
	at com.twitter.util.Await$.result(Awaitable.scala:155)
	at org.apache.texera.amber.engine.e2e.TestUtils$.runWorkflowAndReadResults(TestUtils.scala:187)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.runAndGetMaterializedRowCounts(LoopIntegrationSpec.scala:163)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.$anonfun$new$10(LoopIntegrationSpec.scala:375)
	at org.scalatest.OutcomeOf.outcomeOf(OutcomeOf.scala:85)
	at org.scalatest.OutcomeOf.outcomeOf$(OutcomeOf.scala:83)
	at org.scalatest.OutcomeOf$.outcomeOf(OutcomeOf.scala:104)
	at org.scalatest.Transformer.apply(Transformer.scala:22)
	at org.scalatest.Transformer.apply(Transformer.scala:20)
	at org.scalatest.flatspec.AnyFlatSpecLike$$anon$5.apply(AnyFlatSpecLike.scala:1832)
	at org.scalatest.TestSuite.withFixture(TestSuite.scala:196)
	at org.scalatest.TestSuite.withFixture$(TestSuite.scala:195)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.super$withFixture(LoopIntegrationSpec.scala:108)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.$anonfun$withFixture$1(LoopIntegrationSpec.scala:108)
	at org.scalatest.Retries.withRetry(Retries.scala:343)
	at org.scalatest.Retries.withRetry$(Retries.scala:342)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.withRetry(LoopIntegrationSpec.scala:99)
	at org.scalatest.Retries.withRetry(Retries.scala:203)
	at org.scalatest.Retries.withRetry$(Retries.scala:203)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.withRetry(LoopIntegrationSpec.scala:99)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.withFixture(LoopIntegrationSpec.scala:108)
	at org.scalatest.flatspec.AnyFlatSpecLike.invokeWithFixture$1(AnyFlatSpecLike.scala:1830)
	at org.scalatest.flatspec.AnyFlatSpecLike.$anonfun$runTest$1(AnyFlatSpecLike.scala:1842)
	at org.scalatest.SuperEngine.runTestImpl(Engine.scala:306)
	at org.scalatest.flatspec.AnyFlatSpecLike.runTest(AnyFlatSpecLike.scala:1842)
	at org.scalatest.flatspec.AnyFlatSpecLike.runTest$(AnyFlatSpecLike.scala:1824)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.org$scalatest$BeforeAndAfterEach$$super$runTest(LoopIntegrationSpec.scala:99)
	at org.scalatest.BeforeAndAfterEach.runTest(BeforeAndAfterEach.scala:234)
	at org.scalatest.BeforeAndAfterEach.runTest$(BeforeAndAfterEach.scala:227)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.runTest(LoopIntegrationSpec.scala:99)
	at org.scalatest.flatspec.AnyFlatSpecLike.$anonfun$runTests$1(AnyFlatSpecLike.scala:1900)
	at org.scalatest.SuperEngine.$anonfun$runTestsInBranch$1(Engine.scala:413)
	at scala.collection.immutable.List.foreach(List.scala:323)
	at org.scalatest.SuperEngine.traverseSubNodes$1(Engine.scala:401)
	at org.scalatest.SuperEngine.runTestsInBranch(Engine.scala:390)
	at org.scalatest.SuperEngine.$anonfun$runTestsInBranch$1(Engine.scala:427)
	at scala.collection.immutable.List.foreach(List.scala:323)
	at org.scalatest.SuperEngine.traverseSubNodes$1(Engine.scala:401)
	at org.scalatest.SuperEngine.runTestsInBranch(Engine.scala:396)
	at org.scalatest.SuperEngine.runTestsImpl(Engine.scala:475)
	at org.scalatest.flatspec.AnyFlatSpecLike.runTests(AnyFlatSpecLike.scala:1900)
	at org.scalatest.flatspec.AnyFlatSpecLike.runTests$(AnyFlatSpecLike.scala:1899)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.runTests(LoopIntegrationSpec.scala:99)
	at org.scalatest.Suite.run(Suite.scala:1114)
	at org.scalatest.Suite.run$(Suite.scala:1096)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.org$scalatest$flatspec$AnyFlatSpecLike$$super$run(LoopIntegrationSpec.scala:99)
	at org.scalatest.flatspec.AnyFlatSpecLike.$anonfun$run$1(AnyFlatSpecLike.scala:1945)
	at org.scalatest.SuperEngine.runImpl(Engine.scala:535)
	at org.scalatest.flatspec.AnyFlatSpecLike.run(AnyFlatSpecLike.scala:1945)
	at org.scalatest.flatspec.AnyFlatSpecLike.run$(AnyFlatSpecLike.scala:1943)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.org$scalatest$BeforeAndAfterAll$$super$run(LoopIntegrationSpec.scala:99)
	at org.scalatest.BeforeAndAfterAll.liftedTree1$1(BeforeAndAfterAll.scala:213)
	at org.scalatest.BeforeAndAfterAll.run(BeforeAndAfterAll.scala:210)
	at org.scalatest.BeforeAndAfterAll.run$(BeforeAndAfterAll.scala:208)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.run(LoopIntegrationSpec.scala:99)
	at org.scalatest.tools.Framework.org$scalatest$tools$Framework$$runSuite(Framework.scala:321)
	at org.scalatest.tools.Framework$ScalaTestTask.execute(Framework.scala:517)
	at sbt.TestRunner.runTest$1(TestFramework.scala:153)
	at sbt.TestRunner.run(TestFramework.scala:168)
	at sbt.TestFramework$$anon$3$$anonfun$$lessinit$greater$1.$anonfun$apply$1(TestFramework.scala:336)
	at sbt.TestFramework$.sbt$TestFramework$$withContextLoader(TestFramework.scala:296)
	at sbt.TestFramework$$anon$3$$anonfun$$lessinit$greater$1.apply(TestFramework.scala:336)
	at sbt.TestFramework$$anon$3$$anonfun$$lessinit$greater$1.apply(TestFramework.scala:336)
	at sbt.TestFunction.apply(TestFramework.scala:348)
	at sbt.Tests$.$anonfun$toTask$1(Tests.scala:436)
	at sbt.std.Transform$$anon$3.$anonfun$apply$2(Transform.scala:47)
	at sbt.std.Transform$$anon$4.work(Transform.scala:69)
	at sbt.Execute.$anonfun$submit$2(Execute.scala:283)
	at sbt.internal.util.ErrorHandling$.wideConvert(ErrorHandling.scala:24)
	at sbt.Execute.work(Execute.scala:292)
	at sbt.Execute.$anonfun$submit$1(Execute.scala:283)
	at sbt.ConcurrentRestrictions$$anon$4.$anonfun$submitValid$1(ConcurrentRestrictions.scala:265)
	at sbt.CompletionService$$anon$2.call(CompletionService.scala:65)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:539)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
	at java.base/java.lang.Thread.run(Thread.java:840)
View the full list of 1 ❄️ flaky test(s)
org.apache.texera.amber.engine.e2e.LoopIntegrationSpec::Engine should bind a $i reference after two branches of the loop body meet again

Flake rate in main: 7.14% (Passed 26 times, Failed 2 times)

Stack Traces | 181s run time
com.twitter.util.TimeoutException: 1.minutes+30.seconds
	at com.twitter.util.Promise.ready(Promise.scala:680)
	at com.twitter.util.Promise.result(Promise.scala:689)
	at com.twitter.util.Await$.$anonfun$result$1(Awaitable.scala:155)
	at com.twitter.concurrent.LocalScheduler$Activation.blocking(Scheduler.scala:189)
	at com.twitter.concurrent.LocalScheduler.blocking(Scheduler.scala:256)
	at com.twitter.concurrent.Scheduler$.blocking(Scheduler.scala:85)
	at com.twitter.util.Await$.result(Awaitable.scala:155)
	at org.apache.texera.amber.engine.e2e.TestUtils$.runWorkflowAndReadResults(TestUtils.scala:187)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.runAndGetMaterializedRowCounts(LoopIntegrationSpec.scala:163)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.$anonfun$new$12(LoopIntegrationSpec.scala:405)
	at org.scalatest.OutcomeOf.outcomeOf(OutcomeOf.scala:85)
	at org.scalatest.OutcomeOf.outcomeOf$(OutcomeOf.scala:83)
	at org.scalatest.OutcomeOf$.outcomeOf(OutcomeOf.scala:104)
	at org.scalatest.Transformer.apply(Transformer.scala:22)
	at org.scalatest.Transformer.apply(Transformer.scala:20)
	at org.scalatest.flatspec.AnyFlatSpecLike$$anon$5.apply(AnyFlatSpecLike.scala:1832)
	at org.scalatest.TestSuite.withFixture(TestSuite.scala:196)
	at org.scalatest.TestSuite.withFixture$(TestSuite.scala:195)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.super$withFixture(LoopIntegrationSpec.scala:108)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.$anonfun$withFixture$1(LoopIntegrationSpec.scala:108)
	at org.scalatest.Retries.withRetry(Retries.scala:343)
	at org.scalatest.Retries.withRetry$(Retries.scala:342)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.withRetry(LoopIntegrationSpec.scala:99)
	at org.scalatest.Retries.withRetry(Retries.scala:203)
	at org.scalatest.Retries.withRetry$(Retries.scala:203)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.withRetry(LoopIntegrationSpec.scala:99)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.withFixture(LoopIntegrationSpec.scala:108)
	at org.scalatest.flatspec.AnyFlatSpecLike.invokeWithFixture$1(AnyFlatSpecLike.scala:1830)
	at org.scalatest.flatspec.AnyFlatSpecLike.$anonfun$runTest$1(AnyFlatSpecLike.scala:1842)
	at org.scalatest.SuperEngine.runTestImpl(Engine.scala:306)
	at org.scalatest.flatspec.AnyFlatSpecLike.runTest(AnyFlatSpecLike.scala:1842)
	at org.scalatest.flatspec.AnyFlatSpecLike.runTest$(AnyFlatSpecLike.scala:1824)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.org$scalatest$BeforeAndAfterEach$$super$runTest(LoopIntegrationSpec.scala:99)
	at org.scalatest.BeforeAndAfterEach.runTest(BeforeAndAfterEach.scala:234)
	at org.scalatest.BeforeAndAfterEach.runTest$(BeforeAndAfterEach.scala:227)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.runTest(LoopIntegrationSpec.scala:99)
	at org.scalatest.flatspec.AnyFlatSpecLike.$anonfun$runTests$1(AnyFlatSpecLike.scala:1900)
	at org.scalatest.SuperEngine.$anonfun$runTestsInBranch$1(Engine.scala:413)
	at scala.collection.immutable.List.foreach(List.scala:323)
	at org.scalatest.SuperEngine.traverseSubNodes$1(Engine.scala:401)
	at org.scalatest.SuperEngine.runTestsInBranch(Engine.scala:390)
	at org.scalatest.SuperEngine.$anonfun$runTestsInBranch$1(Engine.scala:427)
	at scala.collection.immutable.List.foreach(List.scala:323)
	at org.scalatest.SuperEngine.traverseSubNodes$1(Engine.scala:401)
	at org.scalatest.SuperEngine.runTestsInBranch(Engine.scala:396)
	at org.scalatest.SuperEngine.runTestsImpl(Engine.scala:475)
	at org.scalatest.flatspec.AnyFlatSpecLike.runTests(AnyFlatSpecLike.scala:1900)
	at org.scalatest.flatspec.AnyFlatSpecLike.runTests$(AnyFlatSpecLike.scala:1899)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.runTests(LoopIntegrationSpec.scala:99)
	at org.scalatest.Suite.run(Suite.scala:1114)
	at org.scalatest.Suite.run$(Suite.scala:1096)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.org$scalatest$flatspec$AnyFlatSpecLike$$super$run(LoopIntegrationSpec.scala:99)
	at org.scalatest.flatspec.AnyFlatSpecLike.$anonfun$run$1(AnyFlatSpecLike.scala:1945)
	at org.scalatest.SuperEngine.runImpl(Engine.scala:535)
	at org.scalatest.flatspec.AnyFlatSpecLike.run(AnyFlatSpecLike.scala:1945)
	at org.scalatest.flatspec.AnyFlatSpecLike.run$(AnyFlatSpecLike.scala:1943)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.org$scalatest$BeforeAndAfterAll$$super$run(LoopIntegrationSpec.scala:99)
	at org.scalatest.BeforeAndAfterAll.liftedTree1$1(BeforeAndAfterAll.scala:213)
	at org.scalatest.BeforeAndAfterAll.run(BeforeAndAfterAll.scala:210)
	at org.scalatest.BeforeAndAfterAll.run$(BeforeAndAfterAll.scala:208)
	at org.apache.texera.amber.engine.e2e.LoopIntegrationSpec.run(LoopIntegrationSpec.scala:99)
	at org.scalatest.tools.Framework.org$scalatest$tools$Framework$$runSuite(Framework.scala:321)
	at org.scalatest.tools.Framework$ScalaTestTask.execute(Framework.scala:517)
	at sbt.TestRunner.runTest$1(TestFramework.scala:153)
	at sbt.TestRunner.run(TestFramework.scala:168)
	at sbt.TestFramework$$anon$3$$anonfun$$lessinit$greater$1.$anonfun$apply$1(TestFramework.scala:336)
	at sbt.TestFramework$.sbt$TestFramework$$withContextLoader(TestFramework.scala:296)
	at sbt.TestFramework$$anon$3$$anonfun$$lessinit$greater$1.apply(TestFramework.scala:336)
	at sbt.TestFramework$$anon$3$$anonfun$$lessinit$greater$1.apply(TestFramework.scala:336)
	at sbt.TestFunction.apply(TestFramework.scala:348)
	at sbt.Tests$.$anonfun$toTask$1(Tests.scala:436)
	at sbt.std.Transform$$anon$3.$anonfun$apply$2(Transform.scala:47)
	at sbt.std.Transform$$anon$4.work(Transform.scala:69)
	at sbt.Execute.$anonfun$submit$2(Execute.scala:283)
	at sbt.internal.util.ErrorHandling$.wideConvert(ErrorHandling.scala:24)
	at sbt.Execute.work(Execute.scala:292)
	at sbt.Execute.$anonfun$submit$1(Execute.scala:283)
	at sbt.ConcurrentRestrictions$$anon$4.$anonfun$submitValid$1(ConcurrentRestrictions.scala:265)
	at sbt.CompletionService$$anon$2.call(CompletionService.scala:65)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:539)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
	at java.base/java.lang.Thread.run(Thread.java:840)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@yangzhang75
yangzhang75 force-pushed the pin/3-read-paths branch 3 times, most recently from 6ff993b to e1f144b Compare September 28, 2026 21:13
@yangzhang75
yangzhang75 marked this pull request as ready for review October 6, 2026 18:42
@yangzhang75
yangzhang75 marked this pull request as draft October 7, 2026 01:35
yangzhang75 and others added 2 commits October 8, 2026 10:55
A public workflow follows the author's latest content, as publishing has
always done. This adds the other state: the author pins the version they
have now, and the public copy stops moving until they pin again.

`is_public` stays the on/off switch; `published_content` is the pin, NULL
while following. `WorkflowPublishService` owns the two states, and three
endpoints expose them: POST and DELETE `/workflow/pin/{wid}` to pin and
unpin, GET `/workflow/publish-status/{wid}` for what the author is shown.
Publishing and unpublishing move through the same service, so unpublishing
drops the pin rather than leaving a private workflow carrying one.

Two paths are narrowed so a pin can hold. A save wrote the whole row back,
so a publish landing while a save was in flight was silently rolled back,
and a request body could set the publish columns itself; saves now write
only name, description and content. Creating a workflow clears the publish
columns for the same reason.

Nothing reads the pinned copy yet: every workflow is in the following
state it is in today, and nothing on screen changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With a version pinned, a workflow has two copies: the author's working
copy and the frozen one on public show. This routes every read that
serves a viewer without granted access through the frozen copy, and
freezes the name and description with the graph.

`WorkflowPublishService.publicCopyOf` returns the three fields as a
group, so a surface cannot pick up the published graph under a title the
author has not published; `WorkflowAccessResource.hasGrantedAccess` is
the seam that decides which copy a caller gets. Granted access -- owner,
shared, project member -- keeps tracking the author's latest, because
sharing is not publishing.

Name and description freeze because they are as public as the graph: if
only the graph froze, a report about a title could be answered by editing
the title while the pinned copy still advertised it.

Routed through it: opening a workflow, the hub's read, Clone, Duplicate,
`/workflow_name`, `/workflow_description` and the size a listing shows.
A workflow that follows the author's latest -- every workflow today --
is served exactly what it is served now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve the pinned copy to public viewers

2 participants